splera-mcp 0.0.1 → 0.1.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/README.md CHANGED
@@ -1,15 +1,206 @@
1
1
  # splera-mcp
2
2
 
3
- Model Context Protocol (MCP) server for [Splera](https://splera.app) — A/B testing for vibe
4
- coders.
3
+ A/B testing for vibe coders, exposed as a Model Context Protocol (MCP) server. Your AI agent creates experiments, reads results, and ships winners without you leaving chat.
5
4
 
6
- **Status:** Reserved. Full release coming soon.
5
+ <!-- TODO: add demo GIF at docs/demo.gif before v0.2.0 -->
7
6
 
8
- Once live:
9
- - `create_experiment` — spin up an A/B test from
10
- your AI coding agent
11
- - `get_results` — check live stats + ship-readiness
12
- - `ship_winner` — roll out the winning variant
13
- to 100% of traffic
7
+ Three native tools your agent gets once installed: `create_experiment`, `get_results`, `ship_winner`. The agent calls them, edits your code to wire the SDK and variant, and reports stats back in plain English. No dashboard tab, no copy-paste, no test infrastructure to learn.
14
8
 
15
- Follow along at [splera.app](https://splera.app).
9
+ - npm: [`splera-mcp`](https://www.npmjs.com/package/splera-mcp)
10
+ - Web docs: [splera.app/docs/mcp](https://splera.app/docs/mcp)
11
+ - Issues: [github.com/sohaibt/splera/issues](https://github.com/sohaibt/splera/issues)
12
+
13
+ ---
14
+
15
+ ## What you can do
16
+
17
+ Once installed, paste any of these into your agent:
18
+
19
+ > *Create a Splera A/B test for my homepage headline: 'Ship faster.' vs 'Ship with confidence.'*
20
+
21
+ > *How's my checkout experiment doing?*
22
+
23
+ > *Ship the winning variant.*
24
+
25
+ The agent picks the right tool, makes the call, and either edits your code to install the SDK, reports Bayesian stats in human terms, or flips the winning variant server-side (takes effect in ~30 seconds worldwide, no redeploy).
26
+
27
+ ---
28
+
29
+ ## Install
30
+
31
+ ### 1. Generate an API key
32
+
33
+ Go to [splera.app/settings](https://splera.app/settings), click **Generate new key**, name it something like `claude-code-macbook`. Copy the key from the one-time modal. It starts with `splera_live_`. You will not see the full key again. If you close the modal without copying, revoke and regenerate.
34
+
35
+ ### 2. Add the config to your agent
36
+
37
+ | Agent | Config file | Method |
38
+ |---|---|---|
39
+ | **Claude Code** | `~/.config/claude-code/mcp.json` | One-liner (below) or edit JSON |
40
+ | **Cursor** | `~/.cursor/mcp.json` | Edit JSON (or Settings → MCP) |
41
+ | **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Edit JSON |
42
+ | **Zed** | `~/.config/zed/settings.json` (`context_servers` block) | Edit JSON |
43
+ | **Replit Agent** | Not supported in v1 (cloud-hosted, can't load local stdio MCP) | — |
44
+
45
+ #### Claude Code
46
+
47
+ ```bash
48
+ claude mcp add splera npx -y splera-mcp --env SPLERA_API_KEY=splera_live_your_key_here
49
+ ```
50
+
51
+ Then restart Claude Code.
52
+
53
+ #### Cursor / Windsurf
54
+
55
+ Same JSON shape for both, just different file paths.
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "splera": {
61
+ "command": "npx",
62
+ "args": ["-y", "splera-mcp"],
63
+ "env": {
64
+ "SPLERA_API_KEY": "splera_live_your_key_here"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ Restart the agent.
72
+
73
+ #### Zed
74
+
75
+ Add to `~/.config/zed/settings.json`:
76
+
77
+ ```json
78
+ {
79
+ "context_servers": {
80
+ "splera": {
81
+ "command": {
82
+ "path": "npx",
83
+ "args": ["-y", "splera-mcp"],
84
+ "env": { "SPLERA_API_KEY": "splera_live_your_key_here" }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ Restart Zed.
92
+
93
+ ---
94
+
95
+ ## Verify your install
96
+
97
+ ### Option A: check the key from any terminal
98
+
99
+ ```bash
100
+ curl -H "Authorization: Bearer splera_live_your_key_here" https://splera.app/api/me
101
+ ```
102
+
103
+ `200 OK` with a JSON body means the key works. `401` means the key is bad or revoked, regenerate at [splera.app/settings](https://splera.app/settings).
104
+
105
+ ### Option B: check the agent's MCP panel after restart
106
+
107
+ Open your agent's **Server logs** or **MCP** panel. You should see:
108
+
109
+ ```
110
+ splera-mcp: connecting to https://splera.app
111
+ splera-mcp: ready. 3 tools loaded. User: you@example.com
112
+ ```
113
+
114
+ If those lines are missing, the MCP isn't loading. Check your config file path and JSON syntax.
115
+
116
+ ---
117
+
118
+ ## Troubleshooting
119
+
120
+ ### "splera is not available" / "tool not found"
121
+
122
+ The MCP config isn't being read. Check that (1) you edited the right file for your agent (see install table above), (2) the JSON is valid, no trailing commas, (3) you restarted the agent after editing.
123
+
124
+ ### `SPLERA_API_KEY not set`
125
+
126
+ The `env` block in your MCP config is missing or malformed. The key must start with `splera_live_`. Re-check the examples above.
127
+
128
+ ### 401 / `SPLERA_INVALID_API_KEY` on every call
129
+
130
+ The key is wrong, revoked, or truncated during copy. Go to [splera.app/settings](https://splera.app/settings), revoke the existing key, generate a new one, update the config, restart the agent.
131
+
132
+ ### `SPLERA_PLAN_LIMIT_EXCEEDED` on create_experiment
133
+
134
+ You've hit your plan's monthly experiment cap. Options: [upgrade](https://splera.app/pricing) or ship / stop an existing experiment to free a slot. The error `hint` field tells you which plan and how many slots are used.
135
+
136
+ ### `get_results` says NOT_READY forever
137
+
138
+ The SDK isn't reaching the page being tested. Verify the script tag is in place:
139
+
140
+ ```html
141
+ <script src="https://splera.app/sdk/splera.js" data-experiment-id="YOUR-EXPERIMENT-UUID" async></script>
142
+ ```
143
+
144
+ Also confirm the page is receiving real traffic. The dashboard shows live exposure counts at [splera.app/experiments/:id](https://splera.app).
145
+
146
+ ### Agent doesn't use the Splera tool even when I ask
147
+
148
+ Most agents need explicit mentions of the tool until it's familiar. Try prompts like *"use the splera MCP to create an experiment"* instead of generic *"set up an A/B test"*. Once the agent calls it once, it remembers.
149
+
150
+ ---
151
+
152
+ ## Tool reference
153
+
154
+ ### `create_experiment`
155
+
156
+ Create a new A/B test. Returns the experiment ID, variant IDs, and an SDK install snippet the agent can paste into your codebase.
157
+
158
+ **Required:** `name` (3-80 chars), `hypothesis` (10-500 chars), `variants` (2-5 items, control first), `metrics` (1-5 items, each with `name` + `event_name`).
159
+
160
+ **Optional:** `decision_mode` (`quick_decision` / `rigorous` / `bandit`, default `quick_decision`), `platform` (`replit` / `lovable` / `bolt` / `claude_design` / `other`, default `other`).
161
+
162
+ **Example prompt:**
163
+
164
+ > *Create a Splera experiment on my pricing page — test "Start free" vs "Get started" on the primary CTA. Primary metric is signup_completed.*
165
+
166
+ ### `get_results`
167
+
168
+ Fetch current results for an experiment. Returns variant stats, Bayesian leader probability, ship-readiness verdict, and (post-ship) guard-window status. Safe to call once per minute during active experiments. For long-horizon monitoring, use the dashboard.
169
+
170
+ **Required:** `experiment_id` (UUID from `create_experiment`).
171
+
172
+ The response includes a `next_check_recommended_at` timestamp so the agent knows when it's worth checking again, instead of spamming the endpoint.
173
+
174
+ **Example prompt:**
175
+
176
+ > *How's my hero CTA experiment doing? Ready to ship?*
177
+
178
+ ### `ship_winner`
179
+
180
+ Ship the winning variant to 100% of traffic. The SDK flips server-side within 30 seconds worldwide. No redeploy, no code change on your end. After shipping, a guard window monitors the live rate against a frozen pre-ship baseline and emails you if performance degrades.
181
+
182
+ **Required:** `experiment_id`, `winning_variant_id` (the slug-style ID like `variant_b` or `control`, not the variant's display name — use `leading_variant_id` from `get_results`).
183
+
184
+ **Example prompt:**
185
+
186
+ > *Ship variant_b on experiment 7f3e9a2b.*
187
+
188
+ ---
189
+
190
+ ## Privacy
191
+
192
+ - `splera-mcp` makes HTTPS calls to `splera.app` only. No local telemetry, no third-party analytics from this package.
193
+ - Your API key stays on your machine in your agent's MCP config. It's never logged server-side.
194
+ - Keys are stored as `sha256` hashes in our database. Even if our DB were compromised, the raw keys are not recoverable.
195
+ - Revoke any key instantly at [splera.app/settings](https://splera.app/settings). Revocation takes effect within 60 seconds.
196
+
197
+ ---
198
+
199
+ ## Links
200
+
201
+ - [Settings / API keys](https://splera.app/settings) — generate, name, revoke
202
+ - [Pricing](https://splera.app/pricing) — plan limits and upgrade
203
+ - [Web docs](https://splera.app/docs/mcp) — longer-form setup guide with screenshots
204
+ - [GitHub issues](https://github.com/sohaibt/splera/issues) — bug reports and feature requests
205
+
206
+ Built by [Splera](https://splera.app). A/B testing for vibe coders.
@@ -0,0 +1,14 @@
1
+ declare const apiUrl: string;
2
+ /**
3
+ * HTTPS fetch wrapper.
4
+ *
5
+ * Responsibilities:
6
+ * 1. Adds Authorization: Bearer <key> header automatically.
7
+ * 2. Honors X-Splera-MCP-Min-Version header — if the server tells us
8
+ * we're below the minimum supported client, we throw a clean
9
+ * SpleraError with the upgrade hint instead of a confusing schema error.
10
+ * 3. Converts non-2xx responses into SpleraError with a SPLERA_* code
11
+ * so tool handlers never see raw HTTP failures.
12
+ */
13
+ export declare function apiFetch<T>(path: string, init?: RequestInit): Promise<T>;
14
+ export { apiUrl };
@@ -0,0 +1,44 @@
1
+ import { loadConfig, isBelow, CURRENT_MCP_VERSION } from './config.js';
2
+ import { SpleraError, httpStatusToSpleraCode, HINTS } from './errors.js';
3
+ const { apiKey, apiUrl } = loadConfig();
4
+ /**
5
+ * HTTPS fetch wrapper.
6
+ *
7
+ * Responsibilities:
8
+ * 1. Adds Authorization: Bearer <key> header automatically.
9
+ * 2. Honors X-Splera-MCP-Min-Version header — if the server tells us
10
+ * we're below the minimum supported client, we throw a clean
11
+ * SpleraError with the upgrade hint instead of a confusing schema error.
12
+ * 3. Converts non-2xx responses into SpleraError with a SPLERA_* code
13
+ * so tool handlers never see raw HTTP failures.
14
+ */
15
+ export async function apiFetch(path, init = {}) {
16
+ const res = await fetch(`${apiUrl}${path}`, {
17
+ ...init,
18
+ headers: {
19
+ 'Content-Type': 'application/json',
20
+ Authorization: `Bearer ${apiKey}`,
21
+ 'X-Splera-MCP-Version': CURRENT_MCP_VERSION,
22
+ ...init.headers,
23
+ },
24
+ });
25
+ // Version-skew guard. Runs BEFORE the ok check — server might still
26
+ // return 2xx with the header as a soft warning ("you'll break on next
27
+ // breaking change"), and the agent should surface that anyway.
28
+ const minVersion = res.headers.get('X-Splera-MCP-Min-Version');
29
+ if (minVersion && isBelow(CURRENT_MCP_VERSION, minVersion)) {
30
+ throw new SpleraError('SPLERA_MCP_VERSION_TOO_OLD', `splera-mcp ${CURRENT_MCP_VERSION} is below minimum supported version ${minVersion}.`, HINTS.SPLERA_MCP_VERSION_TOO_OLD, 426);
31
+ }
32
+ if (!res.ok) {
33
+ const body = (await res
34
+ .json()
35
+ .catch(() => ({})));
36
+ const { code, hint } = httpStatusToSpleraCode(res.status, body.code);
37
+ throw new SpleraError(code, body.error ?? res.statusText, body.hint ?? hint, res.status);
38
+ }
39
+ return (await res.json());
40
+ }
41
+ // Re-export config values so index.ts + tools can reference them for
42
+ // startup logging and URL construction (e.g. results_url) without
43
+ // re-reading the env.
44
+ export { apiUrl };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Env var parsing + validation. Loaded once at startup.
3
+ *
4
+ * Fails fast (process.exit(1)) on missing or malformed env vars so the
5
+ * user learns the problem in the MCP host's server-logs panel instead of
6
+ * getting a 30-second stuck agent or silent auth failures downstream.
7
+ */
8
+ export interface Config {
9
+ apiKey: string;
10
+ apiUrl: string;
11
+ }
12
+ export declare function loadConfig(): Config;
13
+ /**
14
+ * Semver comparison used by the X-Splera-MCP-Min-Version guard in apiClient.
15
+ * Returns true if `current` is strictly less than `minimum`.
16
+ * Both args are expected to be plain dotted triples (e.g. "0.1.0", "1.2.3").
17
+ * Pre-release / build metadata is NOT supported — we don't ship those for MCP.
18
+ */
19
+ export declare function isBelow(current: string, minimum: string): boolean;
20
+ export declare const CURRENT_MCP_VERSION = "0.1.0";
package/dist/config.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Env var parsing + validation. Loaded once at startup.
3
+ *
4
+ * Fails fast (process.exit(1)) on missing or malformed env vars so the
5
+ * user learns the problem in the MCP host's server-logs panel instead of
6
+ * getting a 30-second stuck agent or silent auth failures downstream.
7
+ */
8
+ export function loadConfig() {
9
+ const apiKey = process.env.SPLERA_API_KEY;
10
+ if (!apiKey) {
11
+ console.error('splera-mcp: SPLERA_API_KEY not set.\n' +
12
+ 'Generate a key at https://splera.app/settings.\n' +
13
+ 'Your MCP config env block should look like:\n' +
14
+ ' { "splera": { "command": "npx", "args": ["-y", "splera-mcp"], ' +
15
+ '"env": { "SPLERA_API_KEY": "splera_live_..." } } }');
16
+ process.exit(1);
17
+ }
18
+ const rawUrl = process.env.SPLERA_API_URL ?? 'https://splera.app';
19
+ let parsed;
20
+ try {
21
+ parsed = new URL(rawUrl);
22
+ }
23
+ catch {
24
+ console.error(`splera-mcp: SPLERA_API_URL is not a valid URL: "${rawUrl}"`);
25
+ process.exit(1);
26
+ }
27
+ const isLocalhost = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
28
+ if (parsed.protocol !== 'https:' && !isLocalhost) {
29
+ console.error(`splera-mcp: SPLERA_API_URL must use https:// (got "${parsed.protocol}").\n` +
30
+ 'Plain http is only allowed for localhost/127.0.0.1 (local dev).');
31
+ process.exit(1);
32
+ }
33
+ return { apiKey, apiUrl: parsed.origin };
34
+ }
35
+ /**
36
+ * Semver comparison used by the X-Splera-MCP-Min-Version guard in apiClient.
37
+ * Returns true if `current` is strictly less than `minimum`.
38
+ * Both args are expected to be plain dotted triples (e.g. "0.1.0", "1.2.3").
39
+ * Pre-release / build metadata is NOT supported — we don't ship those for MCP.
40
+ */
41
+ export function isBelow(current, minimum) {
42
+ const a = current.split('.').map((n) => parseInt(n, 10));
43
+ const b = minimum.split('.').map((n) => parseInt(n, 10));
44
+ for (let i = 0; i < 3; i++) {
45
+ const ai = a[i] ?? 0;
46
+ const bi = b[i] ?? 0;
47
+ if (ai < bi)
48
+ return true;
49
+ if (ai > bi)
50
+ return false;
51
+ }
52
+ return false;
53
+ }
54
+ export const CURRENT_MCP_VERSION = '0.1.0';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * SPLERA_* error taxonomy + HTTP-status-to-code mapping.
3
+ *
4
+ * All agent-facing errors MUST go through here so they land on the agent
5
+ * with a consistent, namespaced prefix. No raw HTTP codes, no generic
6
+ * "internal server error" strings. The agent will surface `hint` to the
7
+ * end user verbatim.
8
+ */
9
+ export declare class SpleraError extends Error {
10
+ code: string;
11
+ hint: string;
12
+ httpStatus?: number;
13
+ constructor(code: string, message: string, hint: string, httpStatus?: number);
14
+ }
15
+ /**
16
+ * User-facing hints per SPLERA_* code. Agents relay these verbatim.
17
+ * Keep them actionable — every hint ends in a thing the user can DO.
18
+ */
19
+ export declare const HINTS: Record<string, string>;
20
+ /**
21
+ * Maps an HTTP status (+ optional server-returned code) to a SPLERA_* code
22
+ * and its hint. If the server already returned a SPLERA_* code we trust it;
23
+ * otherwise we fall back to the status-based mapping.
24
+ */
25
+ export declare function httpStatusToSpleraCode(status: number, serverCode?: string): {
26
+ code: string;
27
+ hint: string;
28
+ };
package/dist/errors.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * SPLERA_* error taxonomy + HTTP-status-to-code mapping.
3
+ *
4
+ * All agent-facing errors MUST go through here so they land on the agent
5
+ * with a consistent, namespaced prefix. No raw HTTP codes, no generic
6
+ * "internal server error" strings. The agent will surface `hint` to the
7
+ * end user verbatim.
8
+ */
9
+ export class SpleraError extends Error {
10
+ code;
11
+ hint;
12
+ httpStatus;
13
+ constructor(code, message, hint, httpStatus) {
14
+ super(message);
15
+ this.name = 'SpleraError';
16
+ this.code = code;
17
+ this.hint = hint;
18
+ this.httpStatus = httpStatus;
19
+ }
20
+ }
21
+ /**
22
+ * User-facing hints per SPLERA_* code. Agents relay these verbatim.
23
+ * Keep them actionable — every hint ends in a thing the user can DO.
24
+ */
25
+ export const HINTS = {
26
+ SPLERA_INVALID_API_KEY: 'Check SPLERA_API_KEY or regenerate at https://splera.app/settings.',
27
+ SPLERA_PLAN_LIMIT_EXCEEDED: 'Upgrade at https://splera.app/pricing, or ship/stop an existing experiment to free a slot.',
28
+ SPLERA_DOMAIN_NOT_ALLOWED: 'This experiment is locked to specific domains. Add the requesting origin at the experiment\'s /settings.',
29
+ SPLERA_EXPERIMENT_NOT_FOUND: 'Experiment ID not found. Confirm the UUID from create_experiment.',
30
+ SPLERA_VALIDATION_ERROR: 'Check the tool input — see server message for details.',
31
+ SPLERA_MCP_VERSION_TOO_OLD: 'splera-mcp client is below the minimum supported version. Run: npm update -g splera-mcp',
32
+ SPLERA_HTTP_ERROR: 'Transient Splera API error. Retry in a moment, or check https://splera.app for status.',
33
+ SPLERA_UNKNOWN_TOOL: 'Unknown tool name. Expected one of: create_experiment, get_results, ship_winner.',
34
+ SPLERA_JWT_REQUIRED: 'This action requires a browser session, not an API key. Sign in at https://splera.app/settings.',
35
+ };
36
+ /**
37
+ * Maps an HTTP status (+ optional server-returned code) to a SPLERA_* code
38
+ * and its hint. If the server already returned a SPLERA_* code we trust it;
39
+ * otherwise we fall back to the status-based mapping.
40
+ */
41
+ export function httpStatusToSpleraCode(status, serverCode) {
42
+ if (serverCode && serverCode.startsWith('SPLERA_') && HINTS[serverCode]) {
43
+ return { code: serverCode, hint: HINTS[serverCode] };
44
+ }
45
+ switch (status) {
46
+ case 401:
47
+ return { code: 'SPLERA_INVALID_API_KEY', hint: HINTS.SPLERA_INVALID_API_KEY };
48
+ case 402:
49
+ return { code: 'SPLERA_PLAN_LIMIT_EXCEEDED', hint: HINTS.SPLERA_PLAN_LIMIT_EXCEEDED };
50
+ case 403:
51
+ return { code: 'SPLERA_DOMAIN_NOT_ALLOWED', hint: HINTS.SPLERA_DOMAIN_NOT_ALLOWED };
52
+ case 404:
53
+ return { code: 'SPLERA_EXPERIMENT_NOT_FOUND', hint: HINTS.SPLERA_EXPERIMENT_NOT_FOUND };
54
+ case 422:
55
+ return { code: 'SPLERA_VALIDATION_ERROR', hint: HINTS.SPLERA_VALIDATION_ERROR };
56
+ case 426:
57
+ return { code: 'SPLERA_MCP_VERSION_TOO_OLD', hint: HINTS.SPLERA_MCP_VERSION_TOO_OLD };
58
+ default:
59
+ return { code: 'SPLERA_HTTP_ERROR', hint: HINTS.SPLERA_HTTP_ERROR };
60
+ }
61
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { apiFetch, apiUrl } from './apiClient.js';
6
+ import { wrapToolHandler } from './toolRunner.js';
7
+ import { CURRENT_MCP_VERSION } from './config.js';
8
+ import { createExperimentTool } from './tools/createExperiment.js';
9
+ import { getResultsTool } from './tools/getResults.js';
10
+ import { shipWinnerTool } from './tools/shipWinner.js';
11
+ // Startup log — appears in the MCP host's "Server logs" panel. Users who
12
+ // mistype config see this line appear (or not) and know the MCP is wired
13
+ // up correctly before the first tool call.
14
+ console.error(`splera-mcp: connecting to ${apiUrl}`);
15
+ // Install verification with a 5s timeout. Slow networks should not hang
16
+ // the MCP host — hosts typically kill a server that doesn't respond in ~10s.
17
+ // On timeout or network error: soft-fail (warn + continue). The first real
18
+ // tool call will re-surface the error with full context. On 401: hard-fail
19
+ // with regenerate guidance.
20
+ const VERIFY_TIMEOUT_MS = 5_000;
21
+ try {
22
+ const ctrl = new AbortController();
23
+ const t = setTimeout(() => ctrl.abort(), VERIFY_TIMEOUT_MS);
24
+ const me = await apiFetch('/api/me', {
25
+ signal: ctrl.signal,
26
+ });
27
+ clearTimeout(t);
28
+ console.error(`splera-mcp: ready. 3 tools loaded. User: ${me.user.email}`);
29
+ }
30
+ catch (err) {
31
+ const msg = err.message ?? String(err);
32
+ const code = err.code ?? '';
33
+ if (code === 'SPLERA_INVALID_API_KEY') {
34
+ console.error('splera-mcp: API key rejected (401).\n' +
35
+ 'Regenerate at https://splera.app/settings or verify with:\n' +
36
+ ' curl -H "Authorization: Bearer $SPLERA_API_KEY" https://splera.app/api/me');
37
+ process.exit(1);
38
+ }
39
+ console.error(`splera-mcp: startup verification skipped (${msg}). Will retry on first tool call.`);
40
+ }
41
+ const tools = [createExperimentTool, getResultsTool, shipWinnerTool];
42
+ const server = new Server({ name: 'splera-mcp', version: CURRENT_MCP_VERSION }, { capabilities: { tools: {} } });
43
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
44
+ tools: tools.map((t) => ({
45
+ name: t.name,
46
+ description: t.description,
47
+ inputSchema: t.inputSchema,
48
+ })),
49
+ }));
50
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
51
+ const tool = tools.find((t) => t.name === req.params.name);
52
+ if (!tool) {
53
+ return {
54
+ isError: true,
55
+ content: [
56
+ {
57
+ type: 'text',
58
+ text: `SPLERA_UNKNOWN_TOOL: ${req.params.name}\n\nHint: Expected one of: create_experiment, get_results, ship_winner.`,
59
+ },
60
+ ],
61
+ };
62
+ }
63
+ return wrapToolHandler(tool, req.params.arguments ?? {});
64
+ });
65
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,12 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { Tool } from './types.js';
3
+ /**
4
+ * Wraps a tool handler so any thrown error — SpleraError or otherwise —
5
+ * converts to a proper MCP content array with isError: true + SPLERA_*
6
+ * code in the text block + structured JSON in the resource block.
7
+ *
8
+ * This is the ONLY path by which tool-handler errors reach the agent.
9
+ * The guarantee: every agent-facing error has a SPLERA_* code. No leaked
10
+ * generic JSON-RPC errors, no leaked stack traces.
11
+ */
12
+ export declare function wrapToolHandler(tool: Tool, args: unknown): Promise<CallToolResult>;
@@ -0,0 +1,48 @@
1
+ import { SpleraError } from './errors.js';
2
+ /**
3
+ * Wraps a tool handler so any thrown error — SpleraError or otherwise —
4
+ * converts to a proper MCP content array with isError: true + SPLERA_*
5
+ * code in the text block + structured JSON in the resource block.
6
+ *
7
+ * This is the ONLY path by which tool-handler errors reach the agent.
8
+ * The guarantee: every agent-facing error has a SPLERA_* code. No leaked
9
+ * generic JSON-RPC errors, no leaked stack traces.
10
+ */
11
+ export async function wrapToolHandler(tool, args) {
12
+ try {
13
+ return await tool.handler(args);
14
+ }
15
+ catch (err) {
16
+ const sp = toSpleraError(err);
17
+ return {
18
+ isError: true,
19
+ content: [
20
+ {
21
+ type: 'text',
22
+ text: `${sp.code}: ${sp.message}\n\nHint: ${sp.hint}`,
23
+ },
24
+ {
25
+ type: 'resource',
26
+ resource: {
27
+ uri: `splera-error://${sp.code}`,
28
+ mimeType: 'application/json',
29
+ text: JSON.stringify({
30
+ code: sp.code,
31
+ message: sp.message,
32
+ hint: sp.hint,
33
+ status: sp.httpStatus ?? null,
34
+ }),
35
+ },
36
+ },
37
+ ],
38
+ };
39
+ }
40
+ }
41
+ function toSpleraError(err) {
42
+ if (err instanceof SpleraError)
43
+ return err;
44
+ if (err instanceof Error) {
45
+ return new SpleraError('SPLERA_HTTP_ERROR', err.message, 'Unexpected error. Check Splera API status or retry in a moment.');
46
+ }
47
+ return new SpleraError('SPLERA_HTTP_ERROR', String(err), 'Unexpected error. Check Splera API status or retry in a moment.');
48
+ }
@@ -0,0 +1,2 @@
1
+ import type { Tool } from '../types.js';
2
+ export declare const createExperimentTool: Tool;
@@ -0,0 +1,172 @@
1
+ import { apiFetch, apiUrl } from '../apiClient.js';
2
+ /**
3
+ * Mirror of `packages/web/src/pages/experiments/lib/createExperiment.ts#variantId`.
4
+ * Duplicated rather than imported because @splera/shared doesn't export it and
5
+ * the MCP package is meant to be independently publishable — we'd rather eat
6
+ * the 4 lines of drift risk than add a cross-package dep just for this.
7
+ */
8
+ function variantIdForIndex(index) {
9
+ if (index === 0)
10
+ return 'control';
11
+ // 97 = 'a'; index 1 → 'b', 2 → 'c', ... maxItems=5 so 'e' is the ceiling.
12
+ return `variant_${String.fromCharCode(97 + index)}`;
13
+ }
14
+ export const createExperimentTool = {
15
+ name: 'create_experiment',
16
+ description: 'Create a new A/B test. Returns the experiment ID, variant IDs, and SDK installation instructions. ' +
17
+ 'The installation snippet in the text block is what the agent should paste into the user\'s codebase. ' +
18
+ 'Variant IDs are server-assigned slugs (control, variant_b, variant_c, ...) — use those (not the variant names) ' +
19
+ 'when calling ship_winner later.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ required: ['name', 'hypothesis', 'variants', 'metrics'],
23
+ properties: {
24
+ name: {
25
+ type: 'string',
26
+ minLength: 3,
27
+ maxLength: 80,
28
+ description: 'Short human-readable name.',
29
+ },
30
+ hypothesis: {
31
+ type: 'string',
32
+ minLength: 10,
33
+ maxLength: 500,
34
+ description: 'What you expect will happen and why.',
35
+ },
36
+ variants: {
37
+ type: 'array',
38
+ minItems: 2,
39
+ maxItems: 5,
40
+ items: {
41
+ type: 'object',
42
+ required: ['name'],
43
+ properties: { name: { type: 'string' } },
44
+ },
45
+ description: 'Control first, then 1-4 test variants. IDs are server-generated (control, variant_b, variant_c, ...).',
46
+ },
47
+ // NOTE: spec uses `metrics` (plural, minItems: 1) — NOT singular `metric`.
48
+ // DX review change from design pass.
49
+ metrics: {
50
+ type: 'array',
51
+ minItems: 1,
52
+ maxItems: 5,
53
+ items: {
54
+ type: 'object',
55
+ required: ['name', 'event_name'],
56
+ properties: {
57
+ name: { type: 'string', description: 'Human-readable metric name.' },
58
+ event_name: {
59
+ type: 'string',
60
+ description: "Event name the SDK will track, e.g. 'signup_completed'.",
61
+ },
62
+ type: { type: 'string', enum: ['conversion'], default: 'conversion' },
63
+ goal: { type: 'string', enum: ['increase', 'decrease'], default: 'increase' },
64
+ is_primary: {
65
+ type: 'boolean',
66
+ default: false,
67
+ description: 'First metric is implicitly primary if unset.',
68
+ },
69
+ },
70
+ },
71
+ description: 'Primary metric first. Secondary metrics track side-effects (e.g. pageviews alongside signups).',
72
+ },
73
+ decision_mode: {
74
+ type: 'string',
75
+ enum: ['quick_decision', 'rigorous', 'bandit'],
76
+ default: 'quick_decision',
77
+ },
78
+ platform: {
79
+ type: 'string',
80
+ enum: ['replit', 'lovable', 'bolt', 'claude_design', 'other'],
81
+ default: 'other',
82
+ },
83
+ },
84
+ },
85
+ handler: async (rawArgs) => {
86
+ const args = rawArgs;
87
+ // Build the payload the API expects. The API route reads `variants[i].id`
88
+ // directly (no server-side slug generation) and stores the array verbatim
89
+ // in JSONB, so we assign IDs here. First variant is implicitly control.
90
+ const variants = args.variants.map((v, i) => ({
91
+ id: variantIdForIndex(i),
92
+ name: v.name,
93
+ is_control: i === 0,
94
+ }));
95
+ // First metric becomes primary if none explicitly marked. Matches the web
96
+ // path's convention — keeps /results working without extra branching.
97
+ const hasExplicitPrimary = (args.metrics ?? []).some((m) => m.is_primary === true);
98
+ const metrics = (args.metrics ?? []).map((m, i) => ({
99
+ name: m.name,
100
+ event_name: m.event_name,
101
+ type: m.type ?? 'conversion',
102
+ goal: m.goal ?? 'increase',
103
+ is_primary: hasExplicitPrimary ? m.is_primary === true : i === 0,
104
+ }));
105
+ const body = {
106
+ name: args.name,
107
+ hypothesis: args.hypothesis,
108
+ variants,
109
+ metrics,
110
+ platform: args.platform ?? 'other',
111
+ decision_mode: args.decision_mode ?? 'quick_decision',
112
+ };
113
+ const res = await apiFetch('/api/experiments/create', {
114
+ method: 'POST',
115
+ body: JSON.stringify(body),
116
+ });
117
+ const exp = res.experiment;
118
+ const primaryMetric = metrics.find((m) => m.is_primary) ?? metrics[0];
119
+ const resultsUrl = `https://splera.app/experiments/${exp.id}`;
120
+ // Human-readable text block. The install snippet is the "aha moment" —
121
+ // agents paste it verbatim, so keep the formatting stable.
122
+ const variantLines = variants
123
+ .map((v) => ` ${v.id.padEnd(9)} -> "${v.name}"`)
124
+ .join('\n');
125
+ const text = `Experiment created: "${exp.name}"\n` +
126
+ `ID: ${exp.id}\n` +
127
+ `Variants:\n${variantLines}\n` +
128
+ `Primary metric: ${primaryMetric.event_name} (goal: ${primaryMetric.goal})\n\n` +
129
+ `To activate, add this to your site's <head>:\n` +
130
+ `<script src="https://splera.app/sdk/splera.js" data-experiment-id="${exp.id}" async></script>\n\n` +
131
+ `Then wrap the element you want to vary:\n` +
132
+ `Splera.getVariant('${exp.id}', (variant) => {\n` +
133
+ ` if (variant === 'variant_b') document.querySelector('#cta').textContent = '${variants[1]?.name ?? 'Variant B'}';\n` +
134
+ `});\n\n` +
135
+ `Track conversions:\n` +
136
+ `Splera.track('${primaryMetric.event_name}');\n\n` +
137
+ `View live results: ${resultsUrl}`;
138
+ // Structured JSON block. CRITICAL: agents parse this, not the prose.
139
+ // `apiUrl` from config isn't substituted into results_url because the
140
+ // customer-facing dashboard is always splera.app — apiUrl may be an
141
+ // internal/api subdomain.
142
+ const jsonBlock = {
143
+ experiment_id: exp.id,
144
+ variants: variants.map((v) => ({
145
+ id: v.id,
146
+ name: v.name,
147
+ is_control: v.is_control,
148
+ })),
149
+ primary_metric: {
150
+ name: primaryMetric.name,
151
+ event_name: primaryMetric.event_name,
152
+ goal: primaryMetric.goal,
153
+ },
154
+ sdk_script_url: 'https://splera.app/sdk/splera.js',
155
+ results_url: resultsUrl,
156
+ api_url: apiUrl,
157
+ };
158
+ return {
159
+ content: [
160
+ { type: 'text', text },
161
+ {
162
+ type: 'resource',
163
+ resource: {
164
+ uri: `splera://experiment/${exp.id}`,
165
+ mimeType: 'application/json',
166
+ text: JSON.stringify(jsonBlock),
167
+ },
168
+ },
169
+ ],
170
+ };
171
+ },
172
+ };
@@ -0,0 +1,2 @@
1
+ import type { Tool } from '../types.js';
2
+ export declare const getResultsTool: Tool;
@@ -0,0 +1,196 @@
1
+ import { apiFetch } from '../apiClient.js';
2
+ // SHIP_THRESHOLDS duplicated from @splera/shared — same reason as variantId in
3
+ // createExperiment.ts: keep MCP independently publishable. Drift risk is low
4
+ // (the thresholds are stable product decisions, not implementation details).
5
+ const SHIP_THRESHOLDS = {
6
+ quick_decision: { minExposuresPerVariant: 50, minConfidencePct: 80 },
7
+ rigorous: { minExposuresPerVariant: 200, minConfidencePct: 90 },
8
+ bandit: { minExposuresPerVariant: 30, minConfidencePct: 85 },
9
+ };
10
+ function computeShipReadiness(res) {
11
+ // Post-ship takes precedence regardless of confidence — the decision is made.
12
+ if (res.status === 'concluded') {
13
+ return {
14
+ state: 'post_ship',
15
+ reason: 'Experiment already shipped. Monitor the guard window.',
16
+ sub: null,
17
+ };
18
+ }
19
+ const thresholds = SHIP_THRESHOLDS[res.decision_mode] ?? SHIP_THRESHOLDS.quick_decision;
20
+ const minExposures = res.variants.length > 0
21
+ ? Math.min(...res.variants.map((v) => v.exposures))
22
+ : 0;
23
+ const sampleMet = minExposures >= thresholds.minExposuresPerVariant;
24
+ const confidenceMet = res.confidence_percentage >= thresholds.minConfidencePct;
25
+ if (!sampleMet) {
26
+ return {
27
+ state: 'not_ready',
28
+ reason: `need ${thresholds.minExposuresPerVariant} exposures per variant, have min ${minExposures}`,
29
+ sub: 'below_min_exposures',
30
+ };
31
+ }
32
+ if (!confidenceMet) {
33
+ return {
34
+ state: 'not_ready',
35
+ reason: `below ${thresholds.minConfidencePct}% confidence threshold (currently ${res.confidence_percentage.toFixed(0)}%)`,
36
+ sub: 'below_confidence',
37
+ };
38
+ }
39
+ return {
40
+ state: 'ready',
41
+ reason: `threshold met: ${thresholds.minConfidencePct}% confidence, ${thresholds.minExposuresPerVariant} exposures/variant`,
42
+ sub: null,
43
+ };
44
+ }
45
+ /**
46
+ * Compute the recommended next-check timestamp. Intentionally coarse:
47
+ * - ready -> null (user should act, not wait)
48
+ * - below confidence -> ~6h (waiting for statistical separation)
49
+ * - below min exposures -> ~4h (waiting for more traffic)
50
+ * - post_ship -> ~24h (guard-window cadence)
51
+ */
52
+ function computeNextCheckAt(state, sub) {
53
+ const now = Date.now();
54
+ const HOURS = 60 * 60 * 1000;
55
+ switch (state) {
56
+ case 'ready':
57
+ return null;
58
+ case 'post_ship':
59
+ return new Date(now + 24 * HOURS).toISOString();
60
+ case 'not_ready':
61
+ if (sub === 'below_confidence')
62
+ return new Date(now + 6 * HOURS).toISOString();
63
+ if (sub === 'below_min_exposures')
64
+ return new Date(now + 4 * HOURS).toISOString();
65
+ return new Date(now + 6 * HOURS).toISOString();
66
+ case 'locked':
67
+ return new Date(now + 4 * HOURS).toISOString();
68
+ }
69
+ }
70
+ function fmtPct(rate) {
71
+ return `${(rate * 100).toFixed(1)}%`;
72
+ }
73
+ function fmtInt(n) {
74
+ return n.toLocaleString('en-US');
75
+ }
76
+ export const getResultsTool = {
77
+ name: 'get_results',
78
+ description: 'Get current results for an experiment — variant stats, leader, ship-readiness, and any guard-window alerts. ' +
79
+ 'Use this to check if an experiment is ready to ship. Safe to call once per minute during active experiments. ' +
80
+ 'For long-horizon monitoring, direct the user to https://splera.app/experiments/:id.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ required: ['experiment_id'],
84
+ properties: {
85
+ experiment_id: {
86
+ type: 'string',
87
+ description: 'UUID returned by create_experiment.',
88
+ },
89
+ },
90
+ },
91
+ handler: async (rawArgs) => {
92
+ const args = rawArgs;
93
+ const res = await apiFetch(`/api/experiments/${args.experiment_id}/results`);
94
+ const readiness = computeShipReadiness(res);
95
+ const nextCheckAt = computeNextCheckAt(readiness.state, readiness.sub);
96
+ const leader = res.variants.find((v) => v.variant_id === res.leading_variant_id);
97
+ const resultsUrl = `https://splera.app/experiments/${res.experiment_id}`;
98
+ // Short-id fallback since the API doesn't echo the experiment name back.
99
+ const experimentDisplayName = `Experiment ${res.experiment_id.slice(0, 8)}`;
100
+ // ── Build text output, shape varies by readiness state ─────────────────
101
+ let text;
102
+ if (readiness.state === 'post_ship' && res.guardWindow) {
103
+ const gw = res.guardWindow;
104
+ const dropPct = Math.abs(gw.deltaPct * 100).toFixed(1);
105
+ text =
106
+ `${experimentDisplayName}\n` +
107
+ `Status: concluded (guard window active)\n` +
108
+ `Winning variant: ${res.leading_variant_id}\n\n` +
109
+ `Guard window: ${gw.status.toUpperCase()} (${gw.daysRemaining} days remaining, ` +
110
+ `winner at ${fmtPct(gw.livePostShipRate)} CR vs ${fmtPct(gw.preShipBaselineRate)} baseline, ` +
111
+ `${dropPct}% ${gw.deltaPct < 0 ? 'drop' : 'lift'})\n\n` +
112
+ `Recommendation: Continue monitoring. If the live rate drops more than ` +
113
+ `${(gw.thresholdPct * 100).toFixed(0)}% below the frozen baseline, a rollback alert will fire ` +
114
+ `to your email.\nRollback is available at ${resultsUrl} (dashboard only).\n\n` +
115
+ `Next check recommended after: ${nextCheckAt} (~24 hours)`;
116
+ }
117
+ else {
118
+ const variantLines = res.variants
119
+ .map((v) => {
120
+ const badge = v.variant_id === res.leading_variant_id ? ' <- leading' : '';
121
+ return (` ${v.variant_id.padEnd(10)} ("${v.variant_name}"): ` +
122
+ `${fmtInt(v.exposures).padStart(6)} exposures ` +
123
+ `${fmtInt(v.conversions).padStart(4)} conv ` +
124
+ `${fmtPct(v.conversion_rate).padStart(5)} CR${badge}`);
125
+ })
126
+ .join('\n');
127
+ const readinessLabel = readiness.state === 'ready' ? 'READY' : 'NOT_READY';
128
+ const leaderProbPct = leader?.probability_of_being_best != null
129
+ ? (leader.probability_of_being_best * 100).toFixed(0)
130
+ : res.confidence_percentage.toFixed(0);
131
+ const recommendation = readiness.state === 'ready' && leader
132
+ ? `Ship ${leader.variant_id} now. Call ship_winner with winning_variant_id="${leader.variant_id}".`
133
+ : 'Keep running. Do not call ship_winner yet.';
134
+ const nextCheckLine = nextCheckAt == null
135
+ ? 'Next check recommended after: (N/A - ready to ship)'
136
+ : `Next check recommended after: ${nextCheckAt}`;
137
+ text =
138
+ `${experimentDisplayName}\n` +
139
+ `Status: ${res.status}\n` +
140
+ `Decision mode: ${res.decision_mode}\n\n` +
141
+ `Variants:\n${variantLines}\n\n` +
142
+ `Probability leader is best: ${leaderProbPct}%\n` +
143
+ `Ship readiness: ${readinessLabel} (${readiness.reason})\n` +
144
+ (res.recommendation ? `Reason: ${res.recommendation}\n` : '') +
145
+ `Recommendation: ${recommendation}\n\n` +
146
+ nextCheckLine;
147
+ }
148
+ // ── JSON block: canonical shape per spec (adapts API's field names) ────
149
+ const jsonBlock = {
150
+ experiment_id: res.experiment_id,
151
+ status: res.status,
152
+ decision_mode: res.decision_mode,
153
+ variants: res.variants.map((v) => ({
154
+ id: v.variant_id,
155
+ name: v.variant_name,
156
+ is_control: v.is_control,
157
+ exposures: v.exposures,
158
+ conversions: v.conversions,
159
+ rate: v.conversion_rate,
160
+ probability_best: v.probability_of_being_best,
161
+ is_leading: v.variant_id === res.leading_variant_id,
162
+ })),
163
+ leading_variant_id: res.leading_variant_id ?? null,
164
+ probability_leader_is_best: leader?.probability_of_being_best ?? res.confidence_percentage / 100,
165
+ confidence_percentage: res.confidence_percentage,
166
+ ship_readiness: readiness.state,
167
+ ship_readiness_reason: readiness.reason,
168
+ next_check_recommended_at: nextCheckAt,
169
+ guard_window: res.guardWindow
170
+ ? {
171
+ status: res.guardWindow.status,
172
+ days_remaining: res.guardWindow.daysRemaining,
173
+ baseline_rate: res.guardWindow.preShipBaselineRate,
174
+ live_rate: res.guardWindow.livePostShipRate,
175
+ delta_pct: res.guardWindow.deltaPct,
176
+ threshold_pct: res.guardWindow.thresholdPct,
177
+ ends_at: res.guardWindow.guardWindowEndsAt,
178
+ }
179
+ : null,
180
+ results_url: resultsUrl,
181
+ };
182
+ return {
183
+ content: [
184
+ { type: 'text', text },
185
+ {
186
+ type: 'resource',
187
+ resource: {
188
+ uri: `splera://experiment/${res.experiment_id}/results`,
189
+ mimeType: 'application/json',
190
+ text: JSON.stringify(jsonBlock),
191
+ },
192
+ },
193
+ ],
194
+ };
195
+ },
196
+ };
@@ -0,0 +1,2 @@
1
+ import type { Tool } from '../types.js';
2
+ export declare const shipWinnerTool: Tool;
@@ -0,0 +1,72 @@
1
+ import { apiFetch } from '../apiClient.js';
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ export const shipWinnerTool = {
4
+ name: 'ship_winner',
5
+ description: 'Ship the winning variant to 100% of traffic. The SDK will flip server-side within 30 seconds — no code deploy needed. ' +
6
+ 'Use after get_results confirms the experiment is ship-ready.',
7
+ inputSchema: {
8
+ type: 'object',
9
+ required: ['experiment_id', 'winning_variant_id'],
10
+ properties: {
11
+ experiment_id: {
12
+ type: 'string',
13
+ description: 'UUID returned by create_experiment.',
14
+ },
15
+ winning_variant_id: {
16
+ type: 'string',
17
+ // CRITICAL wording: agents confuse the variant NAME ("Get started")
18
+ // with the variant ID ("variant_b"). The API rejects names with a 400
19
+ // and the error looks like our bug — so spell it out in the schema
20
+ // description, which the agent sees on every tool-selection pass.
21
+ description: "Variant ID from get_results.leading_variant_id (or variants[i].id where is_leading=true). " +
22
+ "NOT the variant name — use the slug-style ID like 'variant_b' or 'control'.",
23
+ },
24
+ },
25
+ },
26
+ handler: async (rawArgs) => {
27
+ const args = rawArgs;
28
+ const res = await apiFetch(`/api/experiments/${args.experiment_id}/ship`, {
29
+ method: 'POST',
30
+ body: JSON.stringify({ winning_variant_id: args.winning_variant_id }),
31
+ });
32
+ const exp = res.experiment;
33
+ const winningVariant = exp.variants.find((v) => v.id === exp.winning_variant_id);
34
+ const winningLabel = winningVariant
35
+ ? `${exp.winning_variant_id} ("${winningVariant.name}")`
36
+ : exp.winning_variant_id;
37
+ // Derive guard_window_ends_at from shipped_at + guard_window_days.
38
+ // The API does not return this composite directly.
39
+ const guardWindowEndsAt = new Date(new Date(exp.shipped_at).getTime() + exp.guard_window_days * DAY_MS).toISOString();
40
+ const guardWindowEndsDate = guardWindowEndsAt.slice(0, 10); // YYYY-MM-DD
41
+ const resultsUrl = `https://splera.app/experiments/${exp.id}`;
42
+ const text = `Shipped ${winningLabel} to 100% of traffic.\n` +
43
+ `SDK will refresh within 30 seconds worldwide — no redeploy needed.\n\n` +
44
+ `Guard window: ${exp.guard_window_days} days (until ${guardWindowEndsDate}).\n` +
45
+ ` - Use get_results to monitor for degradation.\n` +
46
+ ` - If the live rate drops below baseline past the alert threshold, you'll get an email alert.\n` +
47
+ ` - Rollback is manual only (dashboard): ${resultsUrl}\n\n` +
48
+ `Next step (optional): when you're confident the winner is stable, hardcode it\n` +
49
+ `into your codebase and remove the Splera script tag. The dashboard will walk\n` +
50
+ `you through it.`;
51
+ // Flatten to the spec's ShipResponse shape in the JSON block.
52
+ const shipResponse = {
53
+ winning_variant_id: exp.winning_variant_id,
54
+ shipped_at: exp.shipped_at,
55
+ guard_window_ends_at: guardWindowEndsAt,
56
+ results_url: resultsUrl,
57
+ };
58
+ return {
59
+ content: [
60
+ { type: 'text', text },
61
+ {
62
+ type: 'resource',
63
+ resource: {
64
+ uri: `splera://experiment/${exp.id}/ship`,
65
+ mimeType: 'application/json',
66
+ text: JSON.stringify(shipResponse),
67
+ },
68
+ },
69
+ ],
70
+ };
71
+ },
72
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared types for tool handlers + core modules.
3
+ */
4
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
5
+ /**
6
+ * Input schema shape — plain JSON Schema that MCP passes to the client.
7
+ * We keep it loose (Record<string, unknown>) because the MCP SDK doesn't
8
+ * enforce a TypeScript type over it; the validation happens at call time
9
+ * via the input schema the tool declares.
10
+ */
11
+ export type JsonSchema = Record<string, unknown>;
12
+ export interface Tool {
13
+ name: string;
14
+ description: string;
15
+ inputSchema: JsonSchema;
16
+ /**
17
+ * Handler receives the arguments object (already parsed from JSON by the
18
+ * MCP SDK). It returns a full CallToolResult — text + structured resource
19
+ * content blocks — OR throws. Throws are caught by wrapToolHandler() which
20
+ * converts them to SPLERA_* error content blocks.
21
+ */
22
+ handler: (args: unknown) => Promise<CallToolResult>;
23
+ }
24
+ /**
25
+ * Shape returned by `POST /api/experiments/create` → `{ experiment: ExperimentRow }`
26
+ * and by `POST /api/experiments/:id/ship` → `{ experiment: ExperimentRow }`.
27
+ * Matches the DB row. Only the fields tool handlers actually read are typed;
28
+ * the API returns more.
29
+ */
30
+ export interface ExperimentRow {
31
+ id: string;
32
+ name: string;
33
+ hypothesis: string;
34
+ variants: Array<{
35
+ id: string;
36
+ name: string;
37
+ is_control?: boolean;
38
+ }>;
39
+ metrics: Array<{
40
+ name: string;
41
+ event_name: string;
42
+ type?: string;
43
+ goal?: 'increase' | 'decrease';
44
+ is_primary?: boolean;
45
+ }>;
46
+ status: string;
47
+ decision_mode: string;
48
+ platform: string;
49
+ winning_variant_id?: string | null;
50
+ shipped_at?: string | null;
51
+ guard_window_days?: number | null;
52
+ created_at: string;
53
+ }
54
+ /**
55
+ * Adapted from ExperimentRow by shipWinner.ts — it takes the ship-route's
56
+ * `{ experiment: ExperimentRow }` response and derives `guard_window_ends_at`
57
+ * from `shipped_at + guard_window_days`, since the API returns the pieces
58
+ * but not the pre-computed end timestamp.
59
+ */
60
+ export interface ShipResponse {
61
+ winning_variant_id: string;
62
+ shipped_at: string;
63
+ guard_window_ends_at: string;
64
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared types for tool handlers + core modules.
3
+ */
4
+ export {};
package/package.json CHANGED
@@ -1,17 +1,47 @@
1
1
  {
2
2
  "name": "splera-mcp",
3
- "version": "0.0.1",
4
- "description": "Model Context Protocol (MCP) server for Splera — A/B testing for vibe coders. Full release coming soon: https://splera.app",
5
- "scripts": {},
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol (MCP) server for Splera — A/B testing for vibe coders. Create experiments, check results, and ship winners directly from your AI coding agent.",
5
+ "type": "module",
6
+ "bin": {
7
+ "splera-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "typescript": "^5.6.0",
22
+ "vitest": "^4.1.1"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "prepublishOnly": "npm run build"
30
+ },
6
31
  "keywords": [
7
32
  "mcp",
8
33
  "model-context-protocol",
9
34
  "ab-testing",
10
35
  "experiments",
11
- "splera"
36
+ "splera",
37
+ "claude-code",
38
+ "cursor",
39
+ "ai-agent"
12
40
  ],
13
- "author": "",
14
41
  "license": "MIT",
15
- "type": "commonjs",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/sohaibt/splera"
45
+ },
16
46
  "homepage": "https://splera.app"
17
47
  }