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 +21 -0
- package/README.md +120 -0
- package/package.json +44 -0
- package/src/cli.mjs +194 -0
- package/src/lib/color.mjs +27 -0
- package/src/lib/data.mjs +137 -0
- package/src/lib/filters.mjs +85 -0
- package/src/lib/render.mjs +109 -0
- package/src/lib/types.mjs +52 -0
- package/src/mcp-server.mjs +268 -0
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
|
+
}
|
package/src/lib/data.mjs
ADDED
|
@@ -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();
|