tokengratis 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raymond Chin
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,120 @@
1
+ # tokengratis
2
+
3
+ CLI + MCP server for [tokengratis.id](https://tokengratis.id) — the
4
+ aggregator directory of free-tier & free-credit LLM APIs for Indonesian
5
+ developers.
6
+
7
+ Zero runtime dependencies. Plain Node ESM (`node >= 20`). Fetches the live
8
+ directory at request time — no bundled/stale copy shipped in this package.
9
+
10
+ **tokengratis.id is an aggregator, not a verifier.** Every provider and every
11
+ answer from this tool carries provenance (`sources[]` + `syncedAt`) — where
12
+ the data came from and when it was last synced. You will never see the word
13
+ "Verified" here.
14
+
15
+ ## Data source
16
+
17
+ 1. Primary: `https://tokengratis.id/api/providers`
18
+ 2. Fallback (if the API is unreachable): `https://raw.githubusercontent.com/raymondchins/tokengratis-id/main/data/providers.json`
19
+
20
+ Responses are cached in memory for the process and to a short-TTL (1 hour)
21
+ file in the OS temp dir, so repeated CLI invocations don't re-fetch every
22
+ time.
23
+
24
+ ## Install
25
+
26
+ Not yet published to npm. Until it is, run it straight from the repo (see
27
+ [Running from source](#running-from-source) below), or once published:
28
+
29
+ ```bash
30
+ npm install -g tokengratis
31
+ # or run one-off without installing:
32
+ npx -y tokengratis list
33
+ ```
34
+
35
+ ## CLI usage
36
+
37
+ ```
38
+ tokengratis list [--modality <m>] [--min-context <ctx>] [--json]
39
+ tokengratis show <slug> [--json]
40
+ tokengratis search <query> [--json]
41
+ tokengratis models [--provider <slug>] [--json]
42
+ tokengratis --help
43
+ tokengratis --version
44
+ ```
45
+
46
+ Examples:
47
+
48
+ ```bash
49
+ tokengratis list
50
+ tokengratis list --modality vision
51
+ tokengratis list --min-context 128K --json
52
+ tokengratis show openrouter
53
+ tokengratis search llama
54
+ tokengratis models --provider groq
55
+ ```
56
+
57
+ - Plain text output by default; add `--json` to any command for
58
+ machine-readable output.
59
+ - Color is auto-disabled when stdout isn't a TTY, or when `NO_COLOR` is set.
60
+ - Exits non-zero on error (bad slug, network failure, etc.) with a
61
+ human-readable message — never a raw stack trace.
62
+
63
+ ## MCP server usage
64
+
65
+ `tokengratis-mcp` speaks the Model Context Protocol over stdio
66
+ (newline-delimited JSON-RPC 2.0), implemented by hand with no SDK. It
67
+ exposes four tools:
68
+
69
+ - `list_providers` — optional `modality` / `min_context` filters
70
+ - `get_provider` — full detail for one provider by `slug`, incl. provenance
71
+ - `search_models` — substring search across provider/model names and ids
72
+ - `list_models` — flat model list, optional `provider` slug scope
73
+
74
+ ### Claude Code / Cursor config
75
+
76
+ Once published to npm:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "tokengratis": {
82
+ "command": "npx",
83
+ "args": ["-y", "-p", "tokengratis", "tokengratis-mcp"]
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ > `tokengratis-mcp` is a **bin inside** the `tokengratis` package, not a package
90
+ > of its own — hence `-p tokengratis`. Plain `npx -y tokengratis-mcp` would try
91
+ > to fetch a package by that name and fail.
92
+
93
+ ### Running from source (works today, package isn't published yet)
94
+
95
+ Clone the repo, then point your MCP client straight at the local file with
96
+ plain `node` — no install step needed since there are zero dependencies:
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "tokengratis": {
102
+ "command": "node",
103
+ "args": ["/absolute/path/to/tokengratis-id/packages/tokengratis/src/mcp-server.mjs"]
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ (Swap `/absolute/path/to/tokengratis-id` for wherever you cloned
110
+ `https://github.com/raymondchins/tokengratis-id`.)
111
+
112
+ Same trick works for the CLI without installing:
113
+
114
+ ```bash
115
+ node /absolute/path/to/tokengratis-id/packages/tokengratis/src/cli.mjs list
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "tokengratis",
3
+ "version": "0.1.0",
4
+ "description": "CLI + MCP server for tokengratis.id, the aggregator directory of free-tier & free-credit LLM APIs for Indonesian developers. Zero runtime dependencies.",
5
+ "keywords": [
6
+ "tokengratis",
7
+ "llm",
8
+ "free-tier",
9
+ "free-llm",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "cli",
13
+ "openrouter",
14
+ "indonesia"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Raymond Chin",
18
+ "homepage": "https://tokengratis.id",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/raymondchins/tokengratis-id",
22
+ "directory": "packages/tokengratis"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/raymondchins/tokengratis-id/issues"
26
+ },
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "bin": {
32
+ "tokengratis": "./src/cli.mjs",
33
+ "tokengratis-mcp": "./src/mcp-server.mjs"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "dependencies": {},
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // tokengratis — CLI for the tokengratis.id free-tier LLM API directory.
3
+ // Zero dependencies, plain Node ESM.
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { getProviders, DataFetchError } from "./lib/data.mjs";
10
+ import { filterByModality, filterByMinContext, searchProviders, listModels } from "./lib/filters.mjs";
11
+ import { createColorizer } from "./lib/color.mjs";
12
+ import {
13
+ formatProviderListText,
14
+ formatProviderText,
15
+ formatModelListText,
16
+ formatSearchResultsText,
17
+ } from "./lib/render.mjs";
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
21
+
22
+ const HELP = `tokengratis v${pkg.version} — CLI direktori free-tier LLM API (tokengratis.id)
23
+
24
+ Usage:
25
+ tokengratis list [--modality <m>] [--min-context <ctx>] [--json]
26
+ tokengratis show <slug> [--json]
27
+ tokengratis search <query> [--json]
28
+ tokengratis models [--provider <slug>] [--json]
29
+ tokengratis --help
30
+ tokengratis --version
31
+
32
+ Contoh:
33
+ tokengratis list
34
+ tokengratis list --modality vision
35
+ tokengratis list --min-context 128K --json
36
+ tokengratis show openrouter
37
+ tokengratis search llama
38
+ tokengratis models --provider groq
39
+
40
+ Data live dari https://tokengratis.id/api/providers
41
+ (fallback otomatis ke GitHub raw kalau API down). Cached lokal ~1 jam.
42
+
43
+ Ini AGGREGATOR, bukan verifier — tiap provider bawa provenance
44
+ (sources[] + syncedAt), transparan apa adanya dari sumber.`;
45
+
46
+ function getFlagValue(args, name) {
47
+ const idx = args.indexOf(name);
48
+ if (idx === -1 || idx === args.length - 1) return undefined;
49
+ return args[idx + 1];
50
+ }
51
+
52
+ /** Remove known flags (with or without a value) from an args array, leaving positionals. */
53
+ function positionals(args, valueFlags) {
54
+ const out = [];
55
+ for (let i = 0; i < args.length; i++) {
56
+ const a = args[i];
57
+ if (valueFlags.includes(a)) {
58
+ i++; // skip its value
59
+ continue;
60
+ }
61
+ if (a === "--json") continue;
62
+ if (a.startsWith("--")) continue; // unknown flag, ignore rather than crash
63
+ out.push(a);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ async function cmdList(args, { json, color }) {
69
+ const modality = getFlagValue(args, "--modality");
70
+ const minContext = getFlagValue(args, "--min-context");
71
+ const { providers } = await getProviders();
72
+
73
+ let list = providers;
74
+ list = filterByModality(list, modality);
75
+ list = filterByMinContext(list, minContext);
76
+
77
+ if (json) {
78
+ console.log(JSON.stringify(list, null, 2));
79
+ return;
80
+ }
81
+ if (list.length === 0) {
82
+ console.log(formatProviderListText(list, { color }));
83
+ return;
84
+ }
85
+ console.log(formatProviderListText(list, { color }));
86
+ console.log("");
87
+ console.log(color.dim(`${list.length} provider. Detail: tokengratis show <slug>`));
88
+ }
89
+
90
+ async function cmdShow(args, { json, color }) {
91
+ const [slug] = positionals(args, ["--modality", "--min-context", "--provider"]);
92
+ if (!slug) {
93
+ console.error("Error: slug wajib diisi. Contoh: tokengratis show openrouter");
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ const { providers } = await getProviders();
98
+ const p = providers.find((x) => x.slug === slug);
99
+ if (!p) {
100
+ console.error(`Error: provider dengan slug "${slug}" tidak ditemukan. Coba: tokengratis list`);
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ if (json) {
105
+ console.log(JSON.stringify(p, null, 2));
106
+ return;
107
+ }
108
+ console.log(formatProviderText(p, { color }));
109
+ }
110
+
111
+ async function cmdSearch(args, { json, color }) {
112
+ const query = positionals(args, ["--modality", "--min-context", "--provider"]).join(" ").trim();
113
+ if (!query) {
114
+ console.error('Error: query wajib diisi. Contoh: tokengratis search "llama"');
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ const { providers } = await getProviders();
119
+ const results = searchProviders(providers, query);
120
+ if (json) {
121
+ console.log(
122
+ JSON.stringify(
123
+ results.map((r) => ({
124
+ slug: r.provider.slug,
125
+ name: r.provider.name,
126
+ providerMatch: r.providerMatch,
127
+ matchedModels: r.matchedModels,
128
+ })),
129
+ null,
130
+ 2
131
+ )
132
+ );
133
+ return;
134
+ }
135
+ console.log(formatSearchResultsText(results, query, { color }));
136
+ }
137
+
138
+ async function cmdModels(args, { json, color }) {
139
+ const providerSlug = getFlagValue(args, "--provider");
140
+ const { providers } = await getProviders();
141
+ const rows = listModels(providers, providerSlug);
142
+ if (json) {
143
+ console.log(JSON.stringify(rows, null, 2));
144
+ return;
145
+ }
146
+ console.log(formatModelListText(rows, { color }));
147
+ console.log("");
148
+ console.log(color.dim(`${rows.length} model.`));
149
+ }
150
+
151
+ async function main(argv) {
152
+ const args = argv.slice(2);
153
+
154
+ if (args.includes("--version") || args.includes("-v")) {
155
+ console.log(pkg.version);
156
+ return;
157
+ }
158
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
159
+ console.log(HELP);
160
+ return;
161
+ }
162
+
163
+ const [command, ...rest] = args;
164
+ const json = args.includes("--json");
165
+ const color = createColorizer();
166
+
167
+ switch (command) {
168
+ case "list":
169
+ await cmdList(rest, { json, color });
170
+ break;
171
+ case "show":
172
+ await cmdShow(rest, { json, color });
173
+ break;
174
+ case "search":
175
+ await cmdSearch(rest, { json, color });
176
+ break;
177
+ case "models":
178
+ await cmdModels(rest, { json, color });
179
+ break;
180
+ default:
181
+ console.error(`Perintah tidak dikenal: "${command}"\n`);
182
+ console.error(HELP);
183
+ process.exitCode = 1;
184
+ }
185
+ }
186
+
187
+ main(process.argv).catch((err) => {
188
+ if (err instanceof DataFetchError) {
189
+ console.error(`Error: ${err.message}`);
190
+ } else {
191
+ console.error(`Error: ${err && err.message ? err.message : err}`);
192
+ }
193
+ process.exitCode = 1;
194
+ });
@@ -0,0 +1,27 @@
1
+ // Minimal raw-ANSI colorizer — no color libraries (zero-dependency rule).
2
+ // Disabled automatically on non-TTY stdout or when NO_COLOR is set, per
3
+ // https://no-color.org/.
4
+
5
+ const CODES = {
6
+ bold: "1",
7
+ dim: "2",
8
+ green: "32",
9
+ cyan: "36",
10
+ yellow: "33",
11
+ red: "31",
12
+ };
13
+
14
+ /** Identity colorizer — used by the MCP server, which must never emit ANSI. */
15
+ export const noColor = Object.fromEntries(
16
+ Object.keys(CODES).map((key) => [key, (text) => String(text)])
17
+ );
18
+
19
+ /**
20
+ * @returns {Record<keyof typeof CODES, (text: string) => string>}
21
+ */
22
+ export function createColorizer() {
23
+ const enabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
24
+ if (!enabled) return noColor;
25
+ const wrap = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
26
+ return Object.fromEntries(Object.entries(CODES).map(([key, code]) => [key, wrap(code)]));
27
+ }
@@ -0,0 +1,137 @@
1
+ // Shared data-fetching module for both the CLI and the MCP server.
2
+ //
3
+ // Fetches the live tokengratis.id provider directory at runtime (no bundled
4
+ // copy shipped with this package). tokengratis.id is itself an AGGREGATOR,
5
+ // not a verifier — this module just moves that data around, it never
6
+ // invents or infers fields that aren't in the upstream JSON.
7
+ //
8
+ // Resolution order:
9
+ // 1. Primary: https://tokengratis.id/api/providers (bare Provider[])
10
+ // 2. Fallback: raw.githubusercontent.com .../data/providers.json
11
+ //
12
+ // Caching:
13
+ // - In-memory for the lifetime of the current process.
14
+ // - A short-TTL file cache in the OS temp dir so back-to-back CLI
15
+ // invocations (separate processes) don't re-fetch every time.
16
+
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+
21
+ export const PRIMARY_URL = "https://tokengratis.id/api/providers";
22
+ export const FALLBACK_URL =
23
+ "https://raw.githubusercontent.com/raymondchins/tokengratis-id/main/data/providers.json";
24
+
25
+ const FETCH_TIMEOUT_MS = 8000;
26
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
27
+ const CACHE_FILE = path.join(os.tmpdir(), "tokengratis-cli-cache.json");
28
+
29
+ /** Thrown for any user-facing data problem (network, bad response, etc). */
30
+ export class DataFetchError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "DataFetchError";
34
+ }
35
+ }
36
+
37
+ /** @type {{ providers: unknown[], fetchedAt: number, source: string } | null} */
38
+ let memoryCache = null;
39
+
40
+ async function fetchJson(url) {
41
+ let res;
42
+ try {
43
+ res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
44
+ } catch (err) {
45
+ if (err && err.name === "TimeoutError") {
46
+ throw new Error(`timeout setelah ${FETCH_TIMEOUT_MS}ms`);
47
+ }
48
+ throw new Error(err?.message || String(err));
49
+ }
50
+ if (!res.ok) {
51
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
52
+ }
53
+ try {
54
+ return await res.json();
55
+ } catch {
56
+ throw new Error("respons bukan JSON valid");
57
+ }
58
+ }
59
+
60
+ function readFileCache() {
61
+ try {
62
+ const raw = fs.readFileSync(CACHE_FILE, "utf8");
63
+ const parsed = JSON.parse(raw);
64
+ if (
65
+ !parsed ||
66
+ typeof parsed.fetchedAt !== "number" ||
67
+ !Array.isArray(parsed.providers)
68
+ ) {
69
+ return null;
70
+ }
71
+ if (Date.now() - parsed.fetchedAt > CACHE_TTL_MS) {
72
+ return null;
73
+ }
74
+ return parsed;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeFileCache(entry) {
81
+ try {
82
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(entry), "utf8");
83
+ } catch {
84
+ // Best-effort — a stale/unwritable temp dir must never break the CLI/MCP server.
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the current provider directory.
90
+ *
91
+ * @param {{ forceRefresh?: boolean }} [opts]
92
+ * @returns {Promise<{ providers: import("./types.mjs").Provider[], fetchedAt: number, source: string }>}
93
+ */
94
+ export async function getProviders(opts = {}) {
95
+ const { forceRefresh = false } = opts;
96
+
97
+ if (memoryCache && !forceRefresh) return memoryCache;
98
+
99
+ if (!forceRefresh) {
100
+ const cached = readFileCache();
101
+ if (cached) {
102
+ memoryCache = cached;
103
+ return cached;
104
+ }
105
+ }
106
+
107
+ let providers;
108
+ let source = PRIMARY_URL;
109
+ let primaryErr;
110
+ try {
111
+ providers = await fetchJson(PRIMARY_URL);
112
+ } catch (err) {
113
+ primaryErr = err;
114
+ source = FALLBACK_URL;
115
+ try {
116
+ providers = await fetchJson(FALLBACK_URL);
117
+ } catch (fallbackErr) {
118
+ throw new DataFetchError(
119
+ "Gagal mengambil data provider tokengratis.id.\n" +
120
+ ` Primary (${PRIMARY_URL}): ${primaryErr.message}\n` +
121
+ ` Fallback (${FALLBACK_URL}): ${fallbackErr.message}\n` +
122
+ "Cek koneksi internet kamu, atau coba lagi sebentar lagi."
123
+ );
124
+ }
125
+ }
126
+
127
+ if (!Array.isArray(providers)) {
128
+ throw new DataFetchError(
129
+ `Respons data provider dari ${source} tidak valid (bukan array JSON).`
130
+ );
131
+ }
132
+
133
+ const entry = { providers, fetchedAt: Date.now(), source };
134
+ memoryCache = entry;
135
+ writeFileCache(entry);
136
+ return entry;
137
+ }
@@ -0,0 +1,85 @@
1
+ // Filtering / searching helpers shared by the CLI and the MCP server.
2
+ // Pure functions only — no I/O here.
3
+
4
+ /**
5
+ * Parse a human-readable context-window string ("128K", "1M", "32000")
6
+ * into a plain token count. Returns null when it can't be parsed.
7
+ * @param {string | null | undefined} value
8
+ * @returns {number | null}
9
+ */
10
+ export function parseContextTokens(value) {
11
+ if (!value) return null;
12
+ const match = String(value).trim().toUpperCase().match(/^([\d.]+)\s*([KM])?$/);
13
+ if (!match) return null;
14
+ const num = parseFloat(match[1]);
15
+ if (Number.isNaN(num)) return null;
16
+ const mult = match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1;
17
+ return num * mult;
18
+ }
19
+
20
+ /**
21
+ * @param {import("./types.mjs").Provider[]} providers
22
+ * @param {string | undefined} modality
23
+ */
24
+ export function filterByModality(providers, modality) {
25
+ if (!modality) return providers;
26
+ const target = modality.trim().toLowerCase();
27
+ return providers.filter((p) =>
28
+ (p.modalities || []).some((m) => String(m).toLowerCase() === target)
29
+ );
30
+ }
31
+
32
+ /**
33
+ * @param {import("./types.mjs").Provider[]} providers
34
+ * @param {string | undefined} minContext
35
+ */
36
+ export function filterByMinContext(providers, minContext) {
37
+ const threshold = parseContextTokens(minContext);
38
+ if (threshold == null) return providers;
39
+ return providers.filter((p) => {
40
+ const value = parseContextTokens(p.maxContext);
41
+ return value != null && value >= threshold;
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Match provider names/slugs and model names/ids against a query substring.
47
+ * @param {import("./types.mjs").Provider[]} providers
48
+ * @param {string} query
49
+ * @returns {{ provider: import("./types.mjs").Provider, matchedModels: import("./types.mjs").Model[], providerMatch: boolean }[]}
50
+ */
51
+ export function searchProviders(providers, query) {
52
+ const q = query.trim().toLowerCase();
53
+ if (!q) return [];
54
+ const results = [];
55
+ for (const p of providers) {
56
+ const providerMatch =
57
+ (p.name || "").toLowerCase().includes(q) || (p.slug || "").toLowerCase().includes(q);
58
+ const matchedModels = (p.models || []).filter(
59
+ (m) =>
60
+ (m.name || "").toLowerCase().includes(q) || (m.id || "").toLowerCase().includes(q)
61
+ );
62
+ if (providerMatch || matchedModels.length > 0) {
63
+ results.push({ provider: p, matchedModels, providerMatch });
64
+ }
65
+ }
66
+ return results;
67
+ }
68
+
69
+ /**
70
+ * Flatten providers -> models, optionally scoped to one provider slug.
71
+ * @param {import("./types.mjs").Provider[]} providers
72
+ * @param {string | undefined} providerSlug
73
+ */
74
+ export function listModels(providers, providerSlug) {
75
+ const scoped = providerSlug
76
+ ? providers.filter((p) => p.slug === providerSlug)
77
+ : providers;
78
+ const rows = [];
79
+ for (const p of scoped) {
80
+ for (const m of p.models || []) {
81
+ rows.push({ provider: p.slug, providerName: p.name, ...m });
82
+ }
83
+ }
84
+ return rows;
85
+ }
@@ -0,0 +1,109 @@
1
+ // Human-readable text rendering, shared by the CLI (with optional ANSI
2
+ // color) and the MCP server (always plain — the LLM reads this text).
3
+ //
4
+ // The project's #1 rule is aggregator-not-verifier: renderers here always
5
+ // carry provenance (sources[] + syncedAt) and NEVER emit the word
6
+ // "Verified" / "Terverifikasi".
7
+
8
+ import { noColor } from "./color.mjs";
9
+
10
+ /**
11
+ * @param {import("./types.mjs").Provider[]} providers
12
+ * @param {{ color?: typeof noColor }} [opts]
13
+ */
14
+ export function formatProviderListText(providers, opts = {}) {
15
+ const color = opts.color || noColor;
16
+ if (providers.length === 0) return "Tidak ada provider yang cocok dengan filter ini.";
17
+ return providers
18
+ .map((p) => {
19
+ const modalities = (p.modalities || []).join(", ") || "-";
20
+ const ctx = p.maxContext || "-";
21
+ return `${color.bold(p.name)} (${color.dim(p.slug)}) models=${p.modelCount} context=${ctx} modalities=${modalities}`;
22
+ })
23
+ .join("\n");
24
+ }
25
+
26
+ /**
27
+ * @param {import("./types.mjs").Provider} p
28
+ * @param {{ color?: typeof noColor }} [opts]
29
+ */
30
+ export function formatProviderText(p, opts = {}) {
31
+ const color = opts.color || noColor;
32
+ const lines = [];
33
+ lines.push(`${color.bold(p.name)} (${p.slug})`);
34
+ if (p.category) lines.push(`Kategori: ${p.category}`);
35
+ if (p.country) lines.push(`Negara (HQ): ${p.flag ? p.flag + " " : ""}${p.country}`);
36
+ if (p.url) lines.push(`Halaman API key: ${p.url}`);
37
+ if (p.baseUrl) lines.push(`Base URL: ${p.baseUrl}`);
38
+ if (p.freeLimit) lines.push(`Free limit: ${p.freeLimit}`);
39
+ if (p.description) lines.push(`Deskripsi (apa adanya dari sumber): ${p.description}`);
40
+ if (p.moreModels) lines.push(`Catatan: ${p.moreModels}`);
41
+ lines.push(`Modalitas: ${(p.modalities || []).join(", ") || "-"}`);
42
+ lines.push(`Max context: ${p.maxContext || "-"}`);
43
+
44
+ lines.push("");
45
+ const models = p.models || [];
46
+ lines.push(color.bold(`Models (${models.length}):`));
47
+ if (models.length === 0) {
48
+ lines.push(" (tidak ada model terstruktur dari sumber)");
49
+ }
50
+ for (const m of models) {
51
+ const parts = [m.name || m.id];
52
+ if (m.context) parts.push(`context=${m.context}`);
53
+ if (m.maxOutput) parts.push(`maxOutput=${m.maxOutput}`);
54
+ if (m.modality) parts.push(`modality=${m.modality}`);
55
+ if (m.rateLimit) parts.push(`rateLimit=${m.rateLimit}`);
56
+ lines.push(` - ${parts.join(" ")}`);
57
+ }
58
+
59
+ lines.push("");
60
+ lines.push(color.bold("Provenance (aggregator, bukan verifier):"));
61
+ for (const s of p.sources || []) {
62
+ lines.push(` - ${s.name}: ${s.url} (synced ${s.syncedAt})`);
63
+ }
64
+ lines.push(`Synced: ${p.syncedAt}`);
65
+ if (p.sourceUpdatedAt) lines.push(`Source last updated: ${p.sourceUpdatedAt}`);
66
+
67
+ return lines.join("\n");
68
+ }
69
+
70
+ /**
71
+ * @param {ReturnType<import("./filters.mjs").listModels>} rows
72
+ * @param {{ color?: typeof noColor }} [opts]
73
+ */
74
+ export function formatModelListText(rows, opts = {}) {
75
+ const color = opts.color || noColor;
76
+ if (rows.length === 0) return "Tidak ada model.";
77
+ return rows
78
+ .map((m) => {
79
+ const parts = [`${color.bold(m.providerName)}/${m.name || m.id}`];
80
+ if (m.context) parts.push(`context=${m.context}`);
81
+ if (m.modality) parts.push(`modality=${m.modality}`);
82
+ if (m.rateLimit) parts.push(`rateLimit=${m.rateLimit}`);
83
+ return parts.join(" ");
84
+ })
85
+ .join("\n");
86
+ }
87
+
88
+ /**
89
+ * @param {ReturnType<import("./filters.mjs").searchProviders>} results
90
+ * @param {string} query
91
+ * @param {{ color?: typeof noColor }} [opts]
92
+ */
93
+ export function formatSearchResultsText(results, query, opts = {}) {
94
+ const color = opts.color || noColor;
95
+ if (results.length === 0) return `Tidak ada hasil untuk "${query}".`;
96
+ const lines = [`Hasil pencarian untuk "${query}" — ${results.length} provider cocok:`, ""];
97
+ for (const r of results) {
98
+ const tag = r.matchedModels.length === 0 && r.providerMatch ? " (cocok nama provider)" : "";
99
+ lines.push(`${color.bold(r.provider.name)} (${r.provider.slug})${tag}`);
100
+ const shown = r.matchedModels.slice(0, 10);
101
+ for (const m of shown) {
102
+ lines.push(` - ${m.name || m.id}`);
103
+ }
104
+ if (r.matchedModels.length > shown.length) {
105
+ lines.push(` ... +${r.matchedModels.length - shown.length} model lain`);
106
+ }
107
+ }
108
+ return lines.join("\n");
109
+ }
@@ -0,0 +1,52 @@
1
+ // JSDoc-only type definitions mirroring tokengratis-id's `lib/types.ts`
2
+ // (the canonical schema). No runtime code — this file exists purely so
3
+ // editors can resolve the `import("./types.mjs").Provider` JSDoc references
4
+ // used elsewhere in this package. Kept in sync loosely; the live JSON from
5
+ // the API is always the source of truth, this package never validates or
6
+ // invents fields beyond what upstream sends.
7
+
8
+ /**
9
+ * @typedef {"provider_api" | "inference_provider"} ProviderCategory
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} Model
14
+ * @property {string} id
15
+ * @property {string} name
16
+ * @property {string | null} context
17
+ * @property {string | null} maxOutput
18
+ * @property {string | null} modality
19
+ * @property {string | null} rateLimit
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} SourceRef
24
+ * @property {string} name
25
+ * @property {string} url
26
+ * @property {string} syncedAt
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} Provider
31
+ * @property {string} slug
32
+ * @property {string} name
33
+ * @property {ProviderCategory | null} category
34
+ * @property {string | null} country
35
+ * @property {string | null} flag
36
+ * @property {string | null} domain
37
+ * @property {string | null} logo
38
+ * @property {string | null} url
39
+ * @property {string | null} baseUrl
40
+ * @property {string} [description]
41
+ * @property {string[]} modalities
42
+ * @property {number} modelCount
43
+ * @property {string | null} maxContext
44
+ * @property {string | null} freeLimit
45
+ * @property {string | null} moreModels
46
+ * @property {Model[]} models
47
+ * @property {SourceRef[]} sources
48
+ * @property {string} syncedAt
49
+ * @property {string | null} sourceUpdatedAt
50
+ */
51
+
52
+ export {};
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ // tokengratis-mcp — Model Context Protocol server for the tokengratis.id
3
+ // free-tier LLM API directory, implemented by hand over stdio
4
+ // (newline-delimited JSON-RPC 2.0). Zero dependencies, no SDK.
5
+ //
6
+ // HARD RULE: nothing but JSON-RPC may ever touch stdout. All logging /
7
+ // diagnostics go to stderr, always.
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import readline from "node:readline";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import { getProviders, DataFetchError } from "./lib/data.mjs";
15
+ import { filterByModality, filterByMinContext, searchProviders, listModels } from "./lib/filters.mjs";
16
+ import {
17
+ formatProviderListText,
18
+ formatProviderText,
19
+ formatModelListText,
20
+ formatSearchResultsText,
21
+ } from "./lib/render.mjs";
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
25
+
26
+ const SERVER_NAME = "tokengratis-mcp";
27
+ const SERVER_VERSION = pkg.version;
28
+ const DEFAULT_PROTOCOL_VERSION = "2025-06-18";
29
+
30
+ const TOOLS = [
31
+ {
32
+ name: "list_providers",
33
+ description:
34
+ "List free-tier LLM API providers from the tokengratis.id directory (Indonesia-focused, community-aggregated — this is an aggregator, not an authority that checks each claim). Optional filters by modality and minimum context window. Each result carries model counts; use get_provider for full detail + provenance.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ modality: {
39
+ type: "string",
40
+ description:
41
+ "Filter by capability, e.g. text, vision, image, audio, video, code, embeddings, reranking.",
42
+ },
43
+ min_context: {
44
+ type: "string",
45
+ description: "Minimum context window, e.g. '128K' or '1M'.",
46
+ },
47
+ },
48
+ additionalProperties: false,
49
+ },
50
+ },
51
+ {
52
+ name: "get_provider",
53
+ description:
54
+ "Get full detail for one tokengratis.id provider by slug: base URL, free-tier limit description, its models, and provenance (which source(s) contributed the data and when each was synced). This is an aggregator, not a verifier.",
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ slug: {
59
+ type: "string",
60
+ description: "Provider slug, e.g. 'openrouter'. Use list_providers to discover slugs.",
61
+ },
62
+ },
63
+ required: ["slug"],
64
+ additionalProperties: false,
65
+ },
66
+ },
67
+ {
68
+ name: "search_models",
69
+ description:
70
+ "Search tokengratis.id providers and models by substring match against provider name/slug and model name/id.",
71
+ inputSchema: {
72
+ type: "object",
73
+ properties: {
74
+ query: { type: "string", description: "Search text, e.g. 'llama' or 'groq'." },
75
+ },
76
+ required: ["query"],
77
+ additionalProperties: false,
78
+ },
79
+ },
80
+ {
81
+ name: "list_models",
82
+ description: "Flat list of models across all tokengratis.id providers, optionally scoped to one provider slug.",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ provider: { type: "string", description: "Optional provider slug to scope results to." },
87
+ },
88
+ additionalProperties: false,
89
+ },
90
+ },
91
+ ];
92
+
93
+ class ToolInputError extends Error {}
94
+ class ToolNotFoundError extends Error {}
95
+
96
+ function textResult(text) {
97
+ return { content: [{ type: "text", text }] };
98
+ }
99
+
100
+ async function callTool(name, rawArgs) {
101
+ const args = rawArgs && typeof rawArgs === "object" ? rawArgs : {};
102
+ const { providers } = await getProviders();
103
+
104
+ switch (name) {
105
+ case "list_providers": {
106
+ let list = providers;
107
+ list = filterByModality(list, args.modality);
108
+ list = filterByMinContext(list, args.min_context);
109
+ return textResult(formatProviderListText(list));
110
+ }
111
+ case "get_provider": {
112
+ if (!args.slug || typeof args.slug !== "string") {
113
+ throw new ToolInputError("Parameter 'slug' wajib diisi (string).");
114
+ }
115
+ const p = providers.find((x) => x.slug === args.slug);
116
+ if (!p) {
117
+ throw new ToolInputError(`Provider dengan slug "${args.slug}" tidak ditemukan.`);
118
+ }
119
+ return textResult(formatProviderText(p));
120
+ }
121
+ case "search_models": {
122
+ if (!args.query || typeof args.query !== "string") {
123
+ throw new ToolInputError("Parameter 'query' wajib diisi (string).");
124
+ }
125
+ const results = searchProviders(providers, args.query);
126
+ return textResult(formatSearchResultsText(results, args.query));
127
+ }
128
+ case "list_models": {
129
+ const rows = listModels(providers, typeof args.provider === "string" ? args.provider : undefined);
130
+ return textResult(formatModelListText(rows));
131
+ }
132
+ default:
133
+ throw new ToolNotFoundError(`Unknown tool: ${name}`);
134
+ }
135
+ }
136
+
137
+ // ---- JSON-RPC plumbing -----------------------------------------------
138
+
139
+ function send(message) {
140
+ process.stdout.write(JSON.stringify(message) + "\n");
141
+ }
142
+
143
+ function sendResult(id, result) {
144
+ send({ jsonrpc: "2.0", id, result });
145
+ }
146
+
147
+ function sendError(id, code, message, data) {
148
+ const error = { code, message };
149
+ if (data !== undefined) error.data = data;
150
+ send({ jsonrpc: "2.0", id, error });
151
+ }
152
+
153
+ /** @param {any} msg */
154
+ async function handleMessage(msg) {
155
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
156
+ // Not a valid single JSON-RPC object. We have no id to reply against
157
+ // meaningfully, but report a parse/invalid-request error anyway.
158
+ sendError(null, -32700, "Parse error: expected a JSON object");
159
+ return;
160
+ }
161
+
162
+ const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
163
+ const { method } = msg;
164
+
165
+ // Notifications (no "id") never get a response, per JSON-RPC 2.0 / MCP.
166
+ if (!hasId) {
167
+ if (method !== "notifications/initialized") {
168
+ console.error(`[${SERVER_NAME}] notification: ${method}`);
169
+ }
170
+ return;
171
+ }
172
+
173
+ try {
174
+ switch (method) {
175
+ case "initialize": {
176
+ const requested = msg.params && msg.params.protocolVersion;
177
+ sendResult(msg.id, {
178
+ protocolVersion: typeof requested === "string" ? requested : DEFAULT_PROTOCOL_VERSION,
179
+ capabilities: { tools: {} },
180
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
181
+ });
182
+ return;
183
+ }
184
+ case "ping": {
185
+ sendResult(msg.id, {});
186
+ return;
187
+ }
188
+ case "tools/list": {
189
+ sendResult(msg.id, { tools: TOOLS });
190
+ return;
191
+ }
192
+ case "tools/call": {
193
+ const params = msg.params || {};
194
+ try {
195
+ const result = await callTool(params.name, params.arguments);
196
+ sendResult(msg.id, result);
197
+ } catch (err) {
198
+ if (err instanceof ToolNotFoundError) {
199
+ sendError(msg.id, -32601, err.message);
200
+ } else if (err instanceof ToolInputError || err instanceof DataFetchError) {
201
+ // Tool-level failure: valid JSON-RPC response, but flagged as an
202
+ // error result so the model sees it as text, not a protocol error.
203
+ sendResult(msg.id, { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true });
204
+ } else {
205
+ sendError(msg.id, -32603, err && err.message ? err.message : String(err));
206
+ }
207
+ }
208
+ return;
209
+ }
210
+ default:
211
+ sendError(msg.id, -32601, `Method not found: ${method}`);
212
+ }
213
+ } catch (err) {
214
+ sendError(msg.id, -32603, err && err.message ? err.message : String(err));
215
+ }
216
+ }
217
+
218
+ function main() {
219
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
220
+
221
+ // Tool calls are async (they fetch over the network). If stdin closes
222
+ // while one is still in flight — e.g. a client that writes its requests
223
+ // and immediately closes the pipe — we must NOT exit until every
224
+ // in-flight response has actually been written to stdout, or replies
225
+ // silently vanish.
226
+ let pending = 0;
227
+ let closing = false;
228
+ const maybeExit = () => {
229
+ if (closing && pending === 0) process.exit(0);
230
+ };
231
+
232
+ rl.on("line", (line) => {
233
+ const trimmed = line.trim();
234
+ if (!trimmed) return;
235
+
236
+ let msg;
237
+ try {
238
+ msg = JSON.parse(trimmed);
239
+ } catch {
240
+ sendError(null, -32700, "Parse error: invalid JSON");
241
+ return;
242
+ }
243
+
244
+ // Be tolerant of JSON-RPC batch arrays even though MCP stdio normally
245
+ // sends one message per line.
246
+ const messages = Array.isArray(msg) ? msg : [msg];
247
+ for (const m of messages) {
248
+ pending++;
249
+ handleMessage(m)
250
+ .catch((err) => {
251
+ console.error(`[${SERVER_NAME}] unhandled error:`, err);
252
+ })
253
+ .finally(() => {
254
+ pending--;
255
+ maybeExit();
256
+ });
257
+ }
258
+ });
259
+
260
+ rl.on("close", () => {
261
+ closing = true;
262
+ maybeExit();
263
+ });
264
+
265
+ console.error(`[${SERVER_NAME}] v${SERVER_VERSION} ready (stdio, JSON-RPC 2.0)`);
266
+ }
267
+
268
+ main();