college-roi-mcp 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/index.js +267 -0
  4. package/package.json +47 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LE TEEN (le-teen.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # college-roi-mcp
2
+
3
+ MCP server for U.S. college-ROI data: 30-year net present value (NPV) for **500 four-year colleges**, lifetime ROI for **19 major categories + 115 CIP subfields**, per-state **best-value rankings**, and the **out-of-state tuition penalty** ranking.
4
+
5
+ Wraps the free, keyless [LE TEEN College ROI API](https://le-teen.com/api) (static JSON, CC BY 4.0). No API key, no signup, no rate limits beyond CDN sanity. Data derives from FREOPP program-level ROI estimates, IPEDS institutional data, and BEA price deflators — methodology at [le-teen.com/methodology](https://le-teen.com/methodology). Dataset DOI: [10.5281/zenodo.21351602](https://doi.org/10.5281/zenodo.21351602).
6
+
7
+ ## Install
8
+
9
+ Requires Node ≥ 18.
10
+
11
+ **Claude Desktop / Claude Code** (`claude_desktop_config.json` or `.mcp.json`):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "college-roi": {
17
+ "command": "npx",
18
+ "args": ["-y", "college-roi-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ **Claude Code CLI:**
25
+
26
+ ```sh
27
+ claude mcp add college-roi -- npx -y college-roi-mcp
28
+ ```
29
+
30
+ Any other MCP client: run `npx -y college-roi-mcp` over stdio.
31
+
32
+ ## Tools
33
+
34
+ | Tool | What it returns |
35
+ |---|---|
36
+ | `list_majors` | Compact ROI rows for 19 categories / 115 subfields — median & mean lifetime ROI, % never break even, break-even age, AI-exposure band. Filter by kind/parent, sort, limit. |
37
+ | `get_major` | Full record for one major: ROI distribution (p25/median/mean/p75), completion-adjusted & dropout ROI, graduates/programs, AI-exposure detail. |
38
+ | `search_colleges` | Search the 500 positive-NPV-envelope colleges by name/state/control — resident & non-resident 30-yr NPV, cost of attendance, median earnings, break-even age. |
39
+ | `get_college` | Full record for one college, incl. IPEDS unitid and FREOPP coverage. |
40
+ | `best_value_colleges` | Each state's best-value four-years at resident pricing (top 15 per state), or a nationwide top-pick summary. |
41
+ | `out_of_state_penalty` | Public four-years ranked by how much 30-yr NPV a non-resident gives up vs a resident. |
42
+ | `get_api_info` | Metadata for the underlying API: endpoints, license, DOI, attribution. |
43
+
44
+ All dollar figures are lifetime/30-year totals in USD. List responses are compact projections with `limit` controls so they stay small in agent contexts; detail tools return the full upstream record.
45
+
46
+ Note the envelope: only colleges that clear a positive-NPV bar appear (500 of ~1,656 U.S. four-years). A school being *absent* is itself a signal.
47
+
48
+ ## Example prompts
49
+
50
+ - "Is a nursing degree worth it compared to computer science?"
51
+ - "Best-value colleges in Michigan for a resident."
52
+ - "How much does going out-of-state to UVA cost me over 30 years?"
53
+ - "Which majors have the worst odds of ever breaking even?"
54
+
55
+ ## Data freshness & caching
56
+
57
+ The upstream API is static JSON regenerated with the dataset; this server caches responses in-process for 24 h (matching the API's CDN cache). Nothing is written anywhere; the server is read-only.
58
+
59
+ ## License & attribution
60
+
61
+ - **Code:** MIT.
62
+ - **Data:** CC BY 4.0 — free to use with attribution. Cite **LE TEEN, le-teen.com** (and FREOPP/IPEDS/BEA as upstream sources).
package/dist/index.js ADDED
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * college-roi-mcp — MCP server over the LE TEEN College ROI API (le-teen.com/api).
4
+ *
5
+ * Wraps the free, keyless, CC BY 4.0 static-JSON API: 30-year NPV for 500 U.S.
6
+ * four-year colleges, lifetime ROI for 19 major categories + 115 CIP subfields,
7
+ * and the standing rankings. Read-only; no auth; data fetched live and cached
8
+ * in-process for 24h (matching the API's CDN cache).
9
+ */
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { z } from "zod";
13
+ const API_BASE = "https://le-teen.com/api/v1";
14
+ const ATTRIBUTION = "Data: LE TEEN College ROI API (le-teen.com/api), CC BY 4.0. Upstream sources: FREOPP, IPEDS, BEA. DOI: 10.5281/zenodo.21351602";
15
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
16
+ // ---------------------------------------------------------------------------
17
+ // Fetch + cache
18
+ // ---------------------------------------------------------------------------
19
+ const cache = new Map();
20
+ async function apiGet(path) {
21
+ const hit = cache.get(path);
22
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS)
23
+ return hit.data;
24
+ const res = await fetch(`${API_BASE}${path}`, {
25
+ headers: { "user-agent": "college-roi-mcp/1.0 (+https://github.com/thomasthinks/college-roi-mcp)" },
26
+ });
27
+ if (res.status === 404) {
28
+ throw new NotFoundError(path);
29
+ }
30
+ if (!res.ok) {
31
+ throw new Error(`API request failed: ${res.status} ${res.statusText} for ${path}`);
32
+ }
33
+ const data = await res.json();
34
+ cache.set(path, { at: Date.now(), data });
35
+ return data;
36
+ }
37
+ class NotFoundError extends Error {
38
+ constructor(path) {
39
+ super(`Not found: ${path}`);
40
+ }
41
+ }
42
+ function ok(payload) {
43
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] };
44
+ }
45
+ function err(message) {
46
+ return { content: [{ type: "text", text: message }], isError: true };
47
+ }
48
+ function slugify(s) {
49
+ return s
50
+ .toLowerCase()
51
+ .replace(/&/g, "and")
52
+ .replace(/[^a-z0-9]+/g, "-")
53
+ .replace(/^-+|-+$/g, "");
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Server
57
+ // ---------------------------------------------------------------------------
58
+ const server = new McpServer({ name: "college-roi-mcp", version: "1.0.0" }, {
59
+ instructions: "Query U.S. college-ROI data: 30-year net present value (NPV) for 500 four-year colleges, " +
60
+ "lifetime ROI for 19 bachelor's-degree major categories and 115 CIP subfields, per-state " +
61
+ "best-value rankings, and the out-of-state tuition penalty ranking. All dollar figures are " +
62
+ "lifetime/30-year totals in USD. Start with list_majors or search_colleges to find slugs, " +
63
+ `then get_major / get_college for full detail. ${ATTRIBUTION}`,
64
+ });
65
+ // --- get_api_info -----------------------------------------------------------
66
+ server.registerTool("get_api_info", {
67
+ title: "About the College ROI API",
68
+ description: "Metadata for the underlying LE TEEN College ROI API: description, license, attribution, DOI, docs URL, and the list of raw endpoints.",
69
+ inputSchema: {},
70
+ }, async () => {
71
+ const idx = await apiGet("/index.json");
72
+ return ok(idx);
73
+ });
74
+ // --- list_majors -------------------------------------------------------------
75
+ server.registerTool("list_majors", {
76
+ title: "List majors by lifetime ROI",
77
+ description: "List bachelor's-degree majors (19 broad categories and/or 115 CIP subfields) with lifetime-ROI stats: " +
78
+ "median/mean lifetime ROI (USD), % of programs that never break even, median break-even age, and AI-exposure band. " +
79
+ "Returns compact rows sorted by the chosen field. Use the returned slug with get_major for full detail.",
80
+ inputSchema: {
81
+ kind: z.enum(["category", "subfield", "all"]).default("all")
82
+ .describe("Filter to broad categories (19), CIP subfields (115), or all 134."),
83
+ parent: z.string().optional()
84
+ .describe("Only subfields of this category slug, e.g. 'engineering'."),
85
+ sort_by: z
86
+ .enum(["median_lifetime_roi_usd", "mean_lifetime_roi_usd", "pct_never_breakeven", "graduates", "name"])
87
+ .default("median_lifetime_roi_usd")
88
+ .describe("Sort field. ROI sorts are descending; name ascending."),
89
+ limit: z.number().int().min(1).max(150).default(50).describe("Max rows returned."),
90
+ },
91
+ }, async ({ kind, parent, sort_by, limit }) => {
92
+ const data = await apiGet("/majors.json");
93
+ let rows = data.majors;
94
+ if (kind !== "all")
95
+ rows = rows.filter((m) => m.kind === kind);
96
+ if (parent) {
97
+ const p = slugify(parent);
98
+ rows = rows.filter((m) => m.parent?.slug === p);
99
+ }
100
+ rows = [...rows].sort((a, b) => sort_by === "name" ? String(a.name).localeCompare(String(b.name)) : (b[sort_by] ?? -Infinity) - (a[sort_by] ?? -Infinity));
101
+ const total = rows.length;
102
+ const majors = rows.slice(0, limit).map((m) => ({
103
+ slug: m.slug,
104
+ kind: m.kind,
105
+ name: m.name,
106
+ parent: m.parent?.slug ?? null,
107
+ median_lifetime_roi_usd: m.median_lifetime_roi_usd,
108
+ mean_lifetime_roi_usd: m.mean_lifetime_roi_usd,
109
+ pct_never_breakeven: m.pct_never_breakeven,
110
+ median_breakeven_age: m.median_breakeven_age,
111
+ graduates: m.graduates,
112
+ ai_exposure_band: m.ai_exposure?.band ?? null,
113
+ }));
114
+ return ok({ total_matching: total, returned: majors.length, majors, attribution: ATTRIBUTION });
115
+ });
116
+ // --- get_major ----------------------------------------------------------------
117
+ server.registerTool("get_major", {
118
+ title: "Get one major's full ROI detail",
119
+ description: "Full lifetime-ROI record for one major category or CIP subfield by slug (e.g. 'nursing', 'computer-science', " +
120
+ "'philosophy-and-religious-studies'): ROI distribution (p25/median/mean/p75), never-break-even %, break-even age, " +
121
+ "completion-adjusted and dropout ROI, graduates/programs counts, and AI-exposure detail. " +
122
+ "Accepts a plain name and will slugify it; use list_majors to discover exact slugs.",
123
+ inputSchema: {
124
+ slug: z.string().describe("Major slug or name, e.g. 'computer-science' or 'Computer Science'."),
125
+ },
126
+ }, async ({ slug }) => {
127
+ const s = slugify(slug);
128
+ try {
129
+ return ok({ ...(await apiGet(`/majors/${s}.json`)), attribution: ATTRIBUTION });
130
+ }
131
+ catch (e) {
132
+ if (e instanceof NotFoundError) {
133
+ const data = await apiGet("/majors.json");
134
+ const near = data.majors
135
+ .filter((m) => m.slug.includes(s) || s.includes(m.slug) || m.name.toLowerCase().includes(slug.toLowerCase()))
136
+ .slice(0, 8)
137
+ .map((m) => m.slug);
138
+ return err(`No major with slug '${s}'.${near.length ? ` Did you mean: ${near.join(", ")}` : " Use list_majors to browse slugs."}`);
139
+ }
140
+ throw e;
141
+ }
142
+ });
143
+ // --- search_colleges -----------------------------------------------------------
144
+ server.registerTool("search_colleges", {
145
+ title: "Search colleges by name/state",
146
+ description: "Search the 500 largest-coverage U.S. four-year colleges that clear the positive-NPV envelope. " +
147
+ "Each row: 30-year NPV at resident and non-resident pricing, total cost of attendance, median earnings 10 years " +
148
+ "after entry, and break-even age. Filter by name substring, state, and control; sorted by resident NPV descending " +
149
+ "unless overridden. Use the returned slug with get_college.",
150
+ inputSchema: {
151
+ query: z.string().optional().describe("Case-insensitive substring of the college name."),
152
+ state: z.string().length(2).optional().describe("Two-letter state code, e.g. 'MI'."),
153
+ control: z.enum(["public", "private-nonprofit", "private-forprofit"]).optional(),
154
+ sort_by: z
155
+ .enum(["npv_30yr_resident_usd", "npv_30yr_nonresident_usd", "median_earnings_10yr_usd", "total_cost_of_attendance_usd", "name"])
156
+ .default("npv_30yr_resident_usd"),
157
+ limit: z.number().int().min(1).max(100).default(25).describe("Max rows returned."),
158
+ },
159
+ }, async ({ query, state, control, sort_by, limit }) => {
160
+ const data = await apiGet("/colleges.json");
161
+ let rows = data.colleges;
162
+ if (query) {
163
+ // Token-AND matching so partial names work ("cal poly" matches
164
+ // "California Polytechnic State University-San Luis Obispo").
165
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
166
+ rows = rows.filter((c) => {
167
+ const hay = `${c.name} ${c.city ?? ""}`.toLowerCase();
168
+ return tokens.every((t) => hay.includes(t));
169
+ });
170
+ }
171
+ if (state)
172
+ rows = rows.filter((c) => c.state === state.toUpperCase());
173
+ if (control)
174
+ rows = rows.filter((c) => c.control === control);
175
+ rows = [...rows].sort((a, b) => sort_by === "name" ? String(a.name).localeCompare(String(b.name)) : (b[sort_by] ?? -Infinity) - (a[sort_by] ?? -Infinity));
176
+ const total = rows.length;
177
+ const colleges = rows.slice(0, limit);
178
+ return ok({
179
+ total_matching: total,
180
+ returned: colleges.length,
181
+ note: "Only colleges clearing the positive-NPV envelope are listed (500 of ~1,656 four-years). Absence from this list is itself a signal.",
182
+ colleges,
183
+ attribution: ATTRIBUTION,
184
+ });
185
+ });
186
+ // --- get_college -----------------------------------------------------------------
187
+ server.registerTool("get_college", {
188
+ title: "Get one college's full record",
189
+ description: "Full record for one college by slug (e.g. 'university-of-michigan-ann-arbor'): 30-year NPV at resident and " +
190
+ "non-resident pricing, total cost of attendance both ways, median earnings 10 years after entry, break-even age, " +
191
+ "IPEDS unitid, and FREOPP program coverage. Accepts a plain name and will slugify it; use search_colleges to discover slugs.",
192
+ inputSchema: {
193
+ slug: z.string().describe("College slug or name, e.g. 'university-of-michigan-ann-arbor'."),
194
+ },
195
+ }, async ({ slug }) => {
196
+ const s = slugify(slug);
197
+ try {
198
+ return ok({ ...(await apiGet(`/colleges/${s}.json`)), attribution: ATTRIBUTION });
199
+ }
200
+ catch (e) {
201
+ if (e instanceof NotFoundError) {
202
+ const data = await apiGet("/colleges.json");
203
+ const q = slug.toLowerCase();
204
+ const near = data.colleges
205
+ .filter((c) => c.slug.includes(s) || c.name.toLowerCase().includes(q))
206
+ .slice(0, 8)
207
+ .map((c) => c.slug);
208
+ return err(`No college with slug '${s}'.${near.length ? ` Did you mean: ${near.join(", ")}` : " Note: only the 500 positive-NPV-envelope colleges are covered; use search_colleges to browse."}`);
209
+ }
210
+ throw e;
211
+ }
212
+ });
213
+ // --- best_value_colleges -----------------------------------------------------------
214
+ server.registerTool("best_value_colleges", {
215
+ title: "Best-value colleges by state",
216
+ description: "Each U.S. state's best-value four-year colleges at resident pricing, ranked by 30-year NPV (top 15 per state). " +
217
+ "Pass a state for its full list; omit it for a nationwide summary (every state's top pick).",
218
+ inputSchema: {
219
+ state: z.string().length(2).optional().describe("Two-letter state code, e.g. 'CA'. Omit for all states' #1."),
220
+ },
221
+ }, async ({ state }) => {
222
+ const data = await apiGet("/rankings/best-value.json");
223
+ if (state) {
224
+ const st = data.states.find((s) => s.state === state.toUpperCase());
225
+ if (!st)
226
+ return err(`No ranking for state '${state}'. Use a two-letter USPS code.`);
227
+ return ok({ ...st, attribution: ATTRIBUTION });
228
+ }
229
+ const summary = data.states.map((s) => ({
230
+ state: s.state,
231
+ state_name: s.state_name,
232
+ top_college: s.colleges[0],
233
+ }));
234
+ return ok({ title: data.title, states: summary, note: "Pass state for the full top-15 list.", attribution: ATTRIBUTION });
235
+ });
236
+ // --- out_of_state_penalty ------------------------------------------------------------
237
+ server.registerTool("out_of_state_penalty", {
238
+ title: "Out-of-state tuition penalty ranking",
239
+ description: "U.S. public four-year colleges ranked by the out-of-state penalty: how much 30-year NPV a non-resident gives up " +
240
+ "versus a resident at the same school. Includes both NPVs and both tuition figures per row.",
241
+ inputSchema: {
242
+ limit: z.number().int().min(1).max(100).default(25).describe("Max rows returned (77 total)."),
243
+ state: z.string().length(2).optional().describe("Filter to one state's public colleges."),
244
+ },
245
+ }, async ({ limit, state }) => {
246
+ const data = await apiGet("/rankings/out-of-state-penalty.json");
247
+ let rows = data.rankings;
248
+ if (state)
249
+ rows = rows.filter((r) => r.state === state.toUpperCase());
250
+ return ok({
251
+ title: data.title,
252
+ total_matching: rows.length,
253
+ returned: Math.min(rows.length, limit),
254
+ rankings: rows.slice(0, limit),
255
+ attribution: ATTRIBUTION,
256
+ });
257
+ });
258
+ // ---------------------------------------------------------------------------
259
+ async function main() {
260
+ const transport = new StdioServerTransport();
261
+ await server.connect(transport);
262
+ console.error("college-roi-mcp running on stdio");
263
+ }
264
+ main().catch((e) => {
265
+ console.error("Fatal:", e);
266
+ process.exit(1);
267
+ });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "college-roi-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for U.S. college ROI data: 30-year NPV for 500 colleges, lifetime ROI for 134 majors, best-value and out-of-state-penalty rankings. Free, keyless, CC BY 4.0.",
5
+ "license": "MIT",
6
+ "mcpName": "io.github.thomasthinks/college-roi-mcp",
7
+ "type": "module",
8
+ "bin": {
9
+ "college-roi-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
16
+ "prepublishOnly": "npm run build",
17
+ "start": "node dist/index.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "model-context-protocol",
25
+ "college",
26
+ "roi",
27
+ "education",
28
+ "tuition",
29
+ "npv",
30
+ "earnings",
31
+ "dataset"
32
+ ],
33
+ "author": "LE TEEN (https://le-teen.com)",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/thomasthinks/college-roi-mcp.git"
37
+ },
38
+ "homepage": "https://le-teen.com/api",
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.12.0",
41
+ "zod": "^3.24.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^24.0.0",
45
+ "typescript": "^5.6.0"
46
+ }
47
+ }