Initial commit: Lean Experiment MCP server v1.0.0

This commit is contained in:
Scott Scoble 2026-05-02 11:30:27 -07:00
commit cd7662b1c7
7 changed files with 1746 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
*.db
*.log

89
README.md Normal file
View file

@ -0,0 +1,89 @@
# Lean Experiment MCP Server
A Model Context Protocol (MCP) server for running Lean Startup experiments. Define hypotheses, track observations, and make data-driven decisions — all from within your agent workflow.
## Why This Exists
Agents need to validate ideas before building. This MCP server gives agents the same Lean Startup tooling that human startups use: hypothesis definition, metric tracking, and explicit persevere/pivot/kill decisions.
## Tools
### `define_experiment`
Create a new experiment with hypothesis, success criteria, and kill criteria.
```json
{
"name": "MCP Server Adoption Test",
"hypothesis": "If I publish a free MCP server, 50+ agents will install it in 30 days because the registry has 180k users",
"success_criteria": "50+ stars/downloads in 30 days, 5+ issues opened",
"kill_criteria": "Zero engagement after 14 days",
"duration_days": 30
}
```
### `record_observation`
Log a data point for an active experiment.
```json
{
"experiment_id": "exp_...",
"metric": "github_stars",
"value": 12,
"notes": "Posted on HN, got 3 upvotes"
}
```
### `get_experiment_status`
Get full status with metrics summary, days remaining, and observations.
### `list_experiments`
List all experiments with status, day count, and observation count.
### `make_decision`
Make a decision: **persevere**, **pivot**, or **kill**.
```json
{
"experiment_id": "exp_...",
"decision": "kill",
"reason": "Zero installs after 14 days. Hypothesis invalidated."
}
```
### `update_experiment`
Update notes or extend duration.
## Installation
### Via npx (no install)
```json
{
"mcpServers": {
"lean-experiments": {
"command": "npx",
"args": ["lean-experiment-mcp"]
}
}
}
```
### Via npm
```bash
npm install -g lean-experiment-mcp
```
## Data Storage
All experiments stored in `~/.lean-experiments/experiments.db` (SQLite).
## Example Workflow
1. **Define:** `define_experiment` with hypothesis and success/kill criteria
2. **Ship:** Build the thing, publish it
3. **Record:** Every few days, log metrics with `record_observation`
4. **Decide:** At the end, use `make_decision` to persevere, pivot, or kill
5. **Learn:** Review all experiments with `list_experiments`
## License
MIT

1177
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "lean-experiment-mcp",
"version": "1.0.0",
"description": "MCP server for running Lean Startup experiments — define hypotheses, track observations, make data-driven decisions",
"main": "dist/index.js",
"type": "module",
"bin": {
"lean-experiment-mcp": "dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "node --test dist/**/*.test.js"
},
"keywords": ["mcp", "lean-startup", "experiment", "hypothesis", "agent-tools"],
"author": "Marcus (PageZero)",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
}

225
src/index.ts Normal file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { ExperimentStore } from "./store.js";
const store = new ExperimentStore();
const server = new Server(
{ name: "lean-experiment-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "define_experiment",
description: "Define a new Lean Startup experiment with hypothesis, success criteria, and kill criteria",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Short name for the experiment" },
hypothesis: { type: "string", description: "If we do X, then Y will happen because Z" },
success_criteria: { type: "string", description: "What success looks like (e.g., '50+ signups in 30 days')" },
kill_criteria: { type: "string", description: "When to stop (e.g., 'zero engagement after 14 days')" },
duration_days: { type: "number", description: "How long to run the experiment", default: 30 },
notes: { type: "string", description: "Optional additional context" },
},
required: ["name", "hypothesis", "success_criteria", "kill_criteria"],
},
},
{
name: "record_observation",
description: "Record a data point or observation for an active experiment",
inputSchema: {
type: "object",
properties: {
experiment_id: { type: "string", description: "ID of the experiment" },
metric: { type: "string", description: "Metric name (e.g., 'signups', 'revenue', 'page_views')" },
value: { type: "number", description: "Numeric value" },
notes: { type: "string", description: "Context for this observation" },
},
required: ["experiment_id", "metric", "value"],
},
},
{
name: "get_experiment_status",
description: "Get current status of an experiment including days remaining, observations, and recommendation",
inputSchema: {
type: "object",
properties: {
experiment_id: { type: "string", description: "ID of the experiment" },
},
required: ["experiment_id"],
},
},
{
name: "list_experiments",
description: "List all experiments with their current status",
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["active", "completed", "killed", "all"], default: "all" },
},
},
},
{
name: "make_decision",
description: "Make a decision on an experiment: persevere, pivot, or kill",
inputSchema: {
type: "object",
properties: {
experiment_id: { type: "string", description: "ID of the experiment" },
decision: { type: "string", enum: ["persevere", "pivot", "kill"], description: "Decision to make" },
reason: { type: "string", description: "Why this decision was made" },
},
required: ["experiment_id", "decision", "reason"],
},
},
{
name: "update_experiment",
description: "Update experiment notes or extend duration",
inputSchema: {
type: "object",
properties: {
experiment_id: { type: "string", description: "ID of the experiment" },
notes: { type: "string", description: "Updated notes" },
duration_days: { type: "number", description: "New total duration" },
},
required: ["experiment_id"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "define_experiment": {
const exp = store.createExperiment(
args.name as string,
args.hypothesis as string,
args.success_criteria as string,
args.kill_criteria as string,
(args.duration_days as number) || 30,
(args.notes as string) || ""
);
return {
content: [{
type: "text",
text: `Experiment defined!\n\nID: ${exp.id}\nName: ${exp.name}\nHypothesis: ${exp.hypothesis}\nSuccess: ${exp.success_criteria}\nKill: ${exp.kill_criteria}\nDuration: ${exp.duration_days} days\nStarted: ${exp.started_at}\n\nUse record_observation with experiment_id="${exp.id}" to track data.`,
}],
};
}
case "record_observation": {
const obs = store.addObservation(
args.experiment_id as string,
args.metric as string,
args.value as number,
(args.notes as string) || ""
);
return {
content: [{
type: "text",
text: `Observation recorded!\n\nExperiment: ${args.experiment_id}\nMetric: ${obs.metric}\nValue: ${obs.value}\nTime: ${obs.timestamp}\n${obs.notes ? `Notes: ${obs.notes}` : ""}`,
}],
};
}
case "get_experiment_status": {
const status = store.getStatus(args.experiment_id as string);
if (!status) {
return {
content: [{ type: "text", text: `Experiment not found: ${args.experiment_id}` }],
isError: true,
};
}
return {
content: [{
type: "text",
text: formatStatus(status),
}],
};
}
case "list_experiments": {
const filter = (args.status as string) || "all";
const exps = store.listExperiments(filter as any);
if (exps.length === 0) {
return { content: [{ type: "text", text: "No experiments found." }] };
}
const lines = exps.map(e =>
`[${e.status.toUpperCase()}] ${e.name} (${e.id}) — Day ${e.days_elapsed}/${e.duration_days} — ${e.observation_count} observations`
);
return { content: [{ type: "text", text: lines.join("\n") }] };
}
case "make_decision": {
const result = store.makeDecision(
args.experiment_id as string,
args.decision as "persevere" | "pivot" | "kill",
args.reason as string
);
if (!result) {
return {
content: [{ type: "text", text: `Experiment not found: ${args.experiment_id}` }],
isError: true,
};
}
return {
content: [{
type: "text",
text: `Decision recorded!\n\nExperiment: ${result.name}\nDecision: ${result.status.toUpperCase()}\nReason: ${result.decision_reason}\nEnded: ${result.ended_at}`,
}],
};
}
case "update_experiment": {
const updated = store.updateExperiment(
args.experiment_id as string,
args.notes as string | undefined,
args.duration_days as number | undefined
);
if (!updated) {
return {
content: [{ type: "text", text: `Experiment not found: ${args.experiment_id}` }],
isError: true,
};
}
return {
content: [{
type: "text",
text: `Experiment updated!\n\nID: ${updated.id}\nName: ${updated.name}\nDuration: ${updated.duration_days} days\nNotes: ${updated.notes || "(none)"}`,
}],
};
}
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
}
});
function formatStatus(s: any): string {
const metrics = Object.entries(s.metrics_by_name)
.map(([k, v]: [string, any]) => ` ${k}: ${v.total} total, ${v.latest} latest, ${v.count} data points`)
.join("\n") || " (no observations yet)";
return `# ${s.name}\nStatus: ${s.status.toUpperCase()}\nDay: ${s.days_elapsed}/${s.duration_days}\nStarted: ${s.started_at}\n${s.ended_at ? `Ended: ${s.ended_at}\n` : ""}\nHypothesis: ${s.hypothesis}\nSuccess: ${s.success_criteria}\nKill: ${s.kill_criteria}\n\nMetrics:\n${metrics}\n\nObservations: ${s.observation_count}\n${s.decision_reason ? `Decision: ${s.decision_reason}` : ""}`;
}
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Lean Experiment MCP server running on stdio");
}
main().catch(console.error);

209
src/store.ts Normal file
View file

@ -0,0 +1,209 @@
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const DB_DIR = join(homedir(), ".lean-experiments");
const DB_PATH = join(DB_DIR, "experiments.db");
export interface Experiment {
id: string;
name: string;
hypothesis: string;
success_criteria: string;
kill_criteria: string;
duration_days: number;
notes: string;
status: "active" | "completed" | "killed";
started_at: string;
ended_at: string | null;
decision_reason: string | null;
}
export interface Observation {
id: string;
experiment_id: string;
metric: string;
value: number;
notes: string;
timestamp: string;
}
export class ExperimentStore {
private db: DatabaseSync;
constructor() {
mkdirSync(DB_DIR, { recursive: true });
this.db = new DatabaseSync(DB_PATH);
this.init();
}
private init() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS experiments (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
hypothesis TEXT NOT NULL,
success_criteria TEXT NOT NULL,
kill_criteria TEXT NOT NULL,
duration_days INTEGER NOT NULL DEFAULT 30,
notes TEXT DEFAULT '',
status TEXT DEFAULT 'active',
started_at TEXT NOT NULL,
ended_at TEXT,
decision_reason TEXT
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS observations (
id TEXT PRIMARY KEY,
experiment_id TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
notes TEXT DEFAULT '',
timestamp TEXT NOT NULL,
FOREIGN KEY (experiment_id) REFERENCES experiments(id)
)
`);
}
createExperiment(
name: string,
hypothesis: string,
successCriteria: string,
killCriteria: string,
durationDays = 30,
notes = ""
): Experiment {
const id = `exp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const startedAt = new Date().toISOString();
const stmt = this.db.prepare(`
INSERT INTO experiments (id, name, hypothesis, success_criteria, kill_criteria, duration_days, notes, status, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, name, hypothesis, successCriteria, killCriteria, durationDays, notes, "active", startedAt);
return {
id,
name,
hypothesis,
success_criteria: successCriteria,
kill_criteria: killCriteria,
duration_days: durationDays,
notes,
status: "active",
started_at: startedAt,
ended_at: null,
decision_reason: null,
};
}
addObservation(experimentId: string, metric: string, value: number, notes = ""): Observation {
const id = `obs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const timestamp = new Date().toISOString();
const stmt = this.db.prepare(`
INSERT INTO observations (id, experiment_id, metric, value, notes, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(id, experimentId, metric, value, notes, timestamp);
return { id, experiment_id: experimentId, metric, value, notes, timestamp };
}
getStatus(experimentId: string): any {
const expStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
const exp = expStmt.get(experimentId) as unknown as Experiment | undefined;
if (!exp) return null;
const obsStmt = this.db.prepare(`SELECT * FROM observations WHERE experiment_id = ? ORDER BY timestamp`);
const observations = obsStmt.all(experimentId) as unknown as Observation[];
const daysElapsed = Math.floor(
(Date.now() - new Date(exp.started_at).getTime()) / (1000 * 60 * 60 * 24)
);
const metricsByName: Record<string, { total: number; latest: number; count: number }> = {};
for (const obs of observations) {
if (!metricsByName[obs.metric]) {
metricsByName[obs.metric] = { total: 0, latest: obs.value, count: 0 };
}
metricsByName[obs.metric].total += obs.value;
metricsByName[obs.metric].latest = obs.value;
metricsByName[obs.metric].count++;
}
return {
...exp,
days_elapsed: daysElapsed,
observation_count: observations.length,
metrics_by_name: metricsByName,
};
}
listExperiments(status?: "active" | "completed" | "killed" | "all"): Array<Experiment & { days_elapsed: number; observation_count: number }> {
let query = `SELECT * FROM experiments`;
if (status && status !== "all") {
query += ` WHERE status = '${status}'`;
}
query += ` ORDER BY started_at DESC`;
const stmt = this.db.prepare(query);
const exps = stmt.all() as unknown as Experiment[];
return exps.map((exp) => {
const obsStmt = this.db.prepare(`SELECT COUNT(*) as count FROM observations WHERE experiment_id = ?`);
const { count } = obsStmt.get(exp.id) as { count: number };
const daysElapsed = Math.floor(
(Date.now() - new Date(exp.started_at).getTime()) / (1000 * 60 * 60 * 24)
);
return { ...exp, days_elapsed: daysElapsed, observation_count: count };
});
}
makeDecision(experimentId: string, decision: "persevere" | "pivot" | "kill", reason: string): Experiment | null {
const expStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
const exp = expStmt.get(experimentId) as unknown as Experiment | undefined;
if (!exp) return null;
const endedAt = new Date().toISOString();
const newStatus = decision === "persevere" ? "active" : decision === "pivot" ? "completed" : "killed";
const stmt = this.db.prepare(`
UPDATE experiments SET status = ?, decision_reason = ?, ended_at = ?
WHERE id = ?
`);
stmt.run(newStatus, reason, endedAt, experimentId);
return { ...exp, status: newStatus, decision_reason: reason, ended_at: endedAt };
}
updateExperiment(experimentId: string, notes?: string, durationDays?: number): Experiment | null {
const expStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
const exp = expStmt.get(experimentId) as unknown as Experiment | undefined;
if (!exp) return null;
const updates: string[] = [];
const values: any[] = [];
if (notes !== undefined) {
updates.push("notes = ?");
values.push(notes);
}
if (durationDays !== undefined) {
updates.push("duration_days = ?");
values.push(durationDays);
}
if (updates.length === 0) return exp;
values.push(experimentId);
const stmt = this.db.prepare(`UPDATE experiments SET ${updates.join(", ")} WHERE id = ?`);
stmt.run(...values);
const updatedStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
return updatedStmt.get(experimentId) as unknown as Experiment;
}
}

16
tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}