@mafdet/mcp 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 +94 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +106 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +36 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +13 -0
- package/dist/search-docs.d.ts +13 -0
- package/dist/search-docs.d.ts.map +1 -0
- package/dist/search-docs.js +52 -0
- package/dist/tools.d.ts +23 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +145 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mafdet AI
|
|
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,94 @@
|
|
|
1
|
+
# @mafdet/mcp
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server that brings
|
|
4
|
+
**read-only Mafdet AI account tools** into Claude Code, Claude Desktop, Cursor,
|
|
5
|
+
Codex CLI, OpenCode, and any other MCP client.
|
|
6
|
+
|
|
7
|
+
It does **not** run model inference — you keep calling Mafdet AI models through
|
|
8
|
+
each tool's normal OpenAI-compatible endpoint. This server only exposes
|
|
9
|
+
read-only account and documentation helpers.
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
|
|
13
|
+
| Tool | Needs a key | What it does |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| `list_models` | no | List Mafdet AI models with provider, tier, and per-1M-token pricing |
|
|
16
|
+
| `search_docs` | no | Search the Mafdet AI documentation (titles, URLs, summaries) |
|
|
17
|
+
| `get_balance` | yes | Your organization's wallet: balance, bonus, frozen, available |
|
|
18
|
+
| `get_usage_summary` | yes | Requests, tokens, and charge over the last N days (1–90) |
|
|
19
|
+
| `get_subscription` | yes | Current plan, status, period, and Playground credit |
|
|
20
|
+
|
|
21
|
+
The account tools are **read-only** — there is no key management, recharge,
|
|
22
|
+
refund, subscription change, or any write operation.
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
Set these environment variables (all optional):
|
|
27
|
+
|
|
28
|
+
- `MAFDET_API_KEY` — a Mafdet AI key **with the `account:read` scope**. Only the
|
|
29
|
+
account tools need it; `list_models` and `search_docs` work without a key.
|
|
30
|
+
Create a key with the scope in the Console (API Keys → "Allow the local MCP
|
|
31
|
+
server to read this account"). Prefer your client's secret/env configuration
|
|
32
|
+
over putting the key on a command line.
|
|
33
|
+
- `MAFDET_BASE_URL` — defaults to `https://backend.mafdet.ai`. Only change it for
|
|
34
|
+
local/staging testing; it must be an `https://` URL.
|
|
35
|
+
- `MAFDET_DOCS_URL` — defaults to `https://docs.mafdet.ai`.
|
|
36
|
+
|
|
37
|
+
## Client setup
|
|
38
|
+
|
|
39
|
+
### Claude Code
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
claude mcp add mafdet --env MAFDET_API_KEY=sk-mafdet-live-... -- npx -y @mafdet/mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Claude Desktop / Cursor / other MCP clients
|
|
46
|
+
|
|
47
|
+
Add to the client's MCP config (`claude_desktop_config.json`, Cursor MCP
|
|
48
|
+
settings, etc.):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"mafdet": {
|
|
54
|
+
"command": "npx",
|
|
55
|
+
"args": ["-y", "@mafdet/mcp"],
|
|
56
|
+
"env": { "MAFDET_API_KEY": "sk-mafdet-live-..." }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Codex CLI (`~/.codex/config.toml`)
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
[mcp_servers.mafdet]
|
|
66
|
+
command = "npx"
|
|
67
|
+
args = ["-y", "@mafdet/mcp"]
|
|
68
|
+
env = { MAFDET_API_KEY = "sk-mafdet-live-..." }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### OpenCode (`opencode.json`)
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcp": {
|
|
76
|
+
"mafdet": {
|
|
77
|
+
"type": "local",
|
|
78
|
+
"command": ["npx", "-y", "@mafdet/mcp"],
|
|
79
|
+
"environment": { "MAFDET_API_KEY": "sk-mafdet-live-..." }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Omit `MAFDET_API_KEY` to use only the public tools (`list_models`,
|
|
86
|
+
`search_docs`).
|
|
87
|
+
|
|
88
|
+
## Notes
|
|
89
|
+
|
|
90
|
+
- Pin a major version (`@mafdet/mcp@0`) in automated setups to avoid surprise
|
|
91
|
+
upgrades.
|
|
92
|
+
- The server writes only JSON-RPC to stdout; diagnostics go to stderr and never
|
|
93
|
+
include your key.
|
|
94
|
+
- Requires Node.js 20+.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type MafdetErrorKind = "unauthorized" | "forbidden" | "rate_limited" | "unavailable" | "network" | "bad_response";
|
|
2
|
+
export declare class MafdetClientError extends Error {
|
|
3
|
+
readonly kind: MafdetErrorKind;
|
|
4
|
+
constructor(kind: MafdetErrorKind, message: string);
|
|
5
|
+
}
|
|
6
|
+
interface GetJsonOptions {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
path: string;
|
|
9
|
+
query?: Record<string, string | number | undefined>;
|
|
10
|
+
authKey?: string;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function getJson(options: GetJsonOptions): Promise<unknown>;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,WAAW,GACX,cAAc,GACd,aAAa,GACb,SAAS,GACT,cAAc,CAAC;AAEnB,qBAAa,iBAAkB,SAAQ,KAAK;aAExB,IAAI,EAAE,eAAe;gBAArB,IAAI,EAAE,eAAe,EACrC,OAAO,EAAE,MAAM;CAKlB;AAED,UAAU,cAAc;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;IAEpD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC,CAkDvE"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { redact } from "./logger.js";
|
|
2
|
+
// MCP-002B: the single HTTP client for every Mafdet call. It enforces a total
|
|
3
|
+
// timeout, a response-body size cap, and JSON validation; attaches the Bearer
|
|
4
|
+
// key ONLY on account requests; and maps every failure to a stable, actionable
|
|
5
|
+
// message that never echoes the upstream body, headers, or the key.
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 12_000;
|
|
7
|
+
const MAX_RESPONSE_BYTES = 2_000_000;
|
|
8
|
+
export class MafdetClientError extends Error {
|
|
9
|
+
kind;
|
|
10
|
+
constructor(kind, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
this.name = "MafdetClientError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function getJson(options) {
|
|
17
|
+
const url = buildUrl(options.baseUrl, options.path, options.query);
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
20
|
+
let response;
|
|
21
|
+
try {
|
|
22
|
+
const headers = { Accept: "application/json" };
|
|
23
|
+
if (options.authKey) {
|
|
24
|
+
headers.Authorization = `Bearer ${options.authKey}`;
|
|
25
|
+
}
|
|
26
|
+
response = await fetch(url, {
|
|
27
|
+
method: "GET",
|
|
28
|
+
headers,
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
redirect: "error",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new MafdetClientError("network", `could not reach Mafdet AI (${describeNetworkError(error)})`);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw mapStatus(response.status);
|
|
41
|
+
}
|
|
42
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
43
|
+
if (!contentType.includes("application/json")) {
|
|
44
|
+
throw new MafdetClientError("bad_response", "Mafdet AI returned an unexpected (non-JSON) response");
|
|
45
|
+
}
|
|
46
|
+
const text = await readBounded(response);
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new MafdetClientError("bad_response", "Mafdet AI returned malformed JSON");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function buildUrl(baseUrl, path, query) {
|
|
55
|
+
const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl);
|
|
56
|
+
if (query) {
|
|
57
|
+
for (const [key, value] of Object.entries(query)) {
|
|
58
|
+
if (value !== undefined) {
|
|
59
|
+
url.searchParams.set(key, String(value));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return url.toString();
|
|
64
|
+
}
|
|
65
|
+
function mapStatus(status) {
|
|
66
|
+
if (status === 401) {
|
|
67
|
+
return new MafdetClientError("unauthorized", "the Mafdet AI API key is missing, invalid, expired, or revoked");
|
|
68
|
+
}
|
|
69
|
+
if (status === 403) {
|
|
70
|
+
return new MafdetClientError("forbidden", "this API key lacks the account:read scope (create a key with it enabled)");
|
|
71
|
+
}
|
|
72
|
+
if (status === 429) {
|
|
73
|
+
return new MafdetClientError("rate_limited", "rate limited by Mafdet AI; wait a moment and try again");
|
|
74
|
+
}
|
|
75
|
+
return new MafdetClientError("unavailable", `Mafdet AI is temporarily unavailable (HTTP ${status})`);
|
|
76
|
+
}
|
|
77
|
+
// Read the response body but never buffer more than the cap.
|
|
78
|
+
async function readBounded(response) {
|
|
79
|
+
const reader = response.body?.getReader();
|
|
80
|
+
if (!reader) {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
const decoder = new TextDecoder();
|
|
84
|
+
let total = 0;
|
|
85
|
+
let out = "";
|
|
86
|
+
for (;;) {
|
|
87
|
+
const { done, value } = await reader.read();
|
|
88
|
+
if (done)
|
|
89
|
+
break;
|
|
90
|
+
total += value.byteLength;
|
|
91
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
92
|
+
await reader.cancel();
|
|
93
|
+
throw new MafdetClientError("bad_response", "Mafdet AI response exceeded the size limit");
|
|
94
|
+
}
|
|
95
|
+
out += decoder.decode(value, { stream: true });
|
|
96
|
+
}
|
|
97
|
+
out += decoder.decode();
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function describeNetworkError(error) {
|
|
101
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
102
|
+
return "timed out";
|
|
103
|
+
}
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
return redact(message);
|
|
106
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AA4BD,wBAAgB,UAAU,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,SAAS,CAO1E"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// MCP-002A: runtime configuration from the environment. MAFDET_API_KEY is
|
|
2
|
+
// optional — only the account tools need it; list_models and search_docs work
|
|
3
|
+
// without a key. Base URLs default to production and may be overridden for
|
|
4
|
+
// local/staging validation. We never print any of these values.
|
|
5
|
+
const DEFAULT_BASE_URL = "https://backend.mafdet.ai";
|
|
6
|
+
const DEFAULT_DOCS_URL = "https://docs.mafdet.ai";
|
|
7
|
+
// Only https is accepted for a remote host, so a key is never sent in the clear.
|
|
8
|
+
// localhost/127.0.0.1 over http is allowed for local development only.
|
|
9
|
+
function sanitizeBaseUrl(raw, fallback) {
|
|
10
|
+
const value = raw?.trim();
|
|
11
|
+
if (!value) {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error(`invalid URL in configuration: ${value}`);
|
|
20
|
+
}
|
|
21
|
+
const isLocalHttp = url.protocol === "http:" &&
|
|
22
|
+
(url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
23
|
+
if (url.protocol !== "https:" && !isLocalHttp) {
|
|
24
|
+
throw new Error("base URL must be https (http allowed only for localhost)");
|
|
25
|
+
}
|
|
26
|
+
// Drop any trailing slash so path joins are predictable.
|
|
27
|
+
return url.origin;
|
|
28
|
+
}
|
|
29
|
+
export function loadConfig(env = process.env) {
|
|
30
|
+
const apiKey = env.MAFDET_API_KEY?.trim();
|
|
31
|
+
return {
|
|
32
|
+
apiKey: apiKey && apiKey.length > 0 ? apiKey : undefined,
|
|
33
|
+
baseUrl: sanitizeBaseUrl(env.MAFDET_BASE_URL, DEFAULT_BASE_URL),
|
|
34
|
+
docsUrl: sanitizeBaseUrl(env.MAFDET_DOCS_URL, DEFAULT_DOCS_URL),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAgBpE,wBAAgB,WAAW,CAAC,MAAM,kCAAe,GAAG,SAAS,CAmE5D"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { logError } from "./logger.js";
|
|
7
|
+
import { getBalance, getSubscription, getUsageSummary, listModels, searchDocsTool, } from "./tools.js";
|
|
8
|
+
// MCP-002A: the stdio entry point. stdout carries only JSON-RPC (the SDK owns
|
|
9
|
+
// it); everything diagnostic goes to stderr via logError. The five tools are
|
|
10
|
+
// read-only; account tools need MAFDET_API_KEY, the two public tools do not.
|
|
11
|
+
export function buildServer(config = loadConfig()) {
|
|
12
|
+
const server = new McpServer({ name: "mafdet-mcp", version: "0.1.0" });
|
|
13
|
+
server.registerTool("list_models", {
|
|
14
|
+
title: "List Mafdet AI models",
|
|
15
|
+
description: "List the models available on Mafdet AI with provider, tier, and per-1M-token pricing. No API key required.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
provider: z.string().min(1).max(40).optional(),
|
|
18
|
+
tier: z.string().min(1).max(40).optional(),
|
|
19
|
+
},
|
|
20
|
+
}, (args) => listModels(config, args));
|
|
21
|
+
server.registerTool("get_balance", {
|
|
22
|
+
title: "Get account balance",
|
|
23
|
+
description: "Read the current organization's wallet balance, bonus, frozen, and available amounts. Requires a MAFDET_API_KEY with the account:read scope.",
|
|
24
|
+
inputSchema: {},
|
|
25
|
+
}, () => getBalance(config));
|
|
26
|
+
server.registerTool("get_usage_summary", {
|
|
27
|
+
title: "Get usage summary",
|
|
28
|
+
description: "Summarize the organization's API usage over a recent window (requests, tokens, charge). Requires account:read.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
days: z.number().int().min(1).max(90).optional(),
|
|
31
|
+
},
|
|
32
|
+
}, (args) => getUsageSummary(config, args));
|
|
33
|
+
server.registerTool("get_subscription", {
|
|
34
|
+
title: "Get subscription",
|
|
35
|
+
description: "Read the organization's current plan, status, period, and Playground credit summary. Requires account:read.",
|
|
36
|
+
inputSchema: {},
|
|
37
|
+
}, () => getSubscription(config));
|
|
38
|
+
server.registerTool("search_docs", {
|
|
39
|
+
title: "Search Mafdet AI docs",
|
|
40
|
+
description: "Search the Mafdet AI documentation and return titles, URLs, and summaries. No API key required.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
query: z.string().min(1).max(100),
|
|
43
|
+
limit: z.number().int().min(1).max(10).optional(),
|
|
44
|
+
},
|
|
45
|
+
}, (args) => searchDocsTool(config, args));
|
|
46
|
+
return server;
|
|
47
|
+
}
|
|
48
|
+
async function main() {
|
|
49
|
+
const server = buildServer();
|
|
50
|
+
const transport = new StdioServerTransport();
|
|
51
|
+
await server.connect(transport);
|
|
52
|
+
const shutdown = () => {
|
|
53
|
+
void server.close().finally(() => process.exit(0));
|
|
54
|
+
};
|
|
55
|
+
process.on("SIGINT", shutdown);
|
|
56
|
+
process.on("SIGTERM", shutdown);
|
|
57
|
+
}
|
|
58
|
+
// Only run when invoked as the binary, not when imported by tests.
|
|
59
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
60
|
+
main().catch((error) => {
|
|
61
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
|
64
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAOA,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAI9C;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE9C"}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// MCP-002A: stdout is the JSON-RPC channel for a stdio MCP server, so ALL
|
|
2
|
+
// diagnostics go to stderr. Never log the API key or an Authorization header —
|
|
3
|
+
// redact anything that looks like one before writing.
|
|
4
|
+
const KEY_LIKE = /sk-mafdet-[a-z-]*[A-Za-z0-9_-]+/g;
|
|
5
|
+
const BEARER = /Bearer\s+[A-Za-z0-9._-]+/gi;
|
|
6
|
+
export function redact(message) {
|
|
7
|
+
return message
|
|
8
|
+
.replace(BEARER, "Bearer [redacted]")
|
|
9
|
+
.replace(KEY_LIKE, "[redacted-key]");
|
|
10
|
+
}
|
|
11
|
+
export function logError(message) {
|
|
12
|
+
process.stderr.write(`[mafdet-mcp] ${redact(message)}\n`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface DocHit {
|
|
2
|
+
title: string;
|
|
3
|
+
url: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function searchDocs(input: {
|
|
7
|
+
docsUrl: string;
|
|
8
|
+
query: string;
|
|
9
|
+
limit: number;
|
|
10
|
+
docsBaseForUrl?: string;
|
|
11
|
+
}): Promise<DocHit[]>;
|
|
12
|
+
export declare function resetDocsCache(): void;
|
|
13
|
+
//# sourceMappingURL=search-docs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-docs.d.ts","sourceRoot":"","sources":["../src/search-docs.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAuCD,wBAAsB,UAAU,CAAC,KAAK,EAAE;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA0BpB;AAGD,wBAAgB,cAAc,IAAI,IAAI,CAErC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getJson, MafdetClientError } from "./client.js";
|
|
2
|
+
let cache;
|
|
3
|
+
async function loadIndex(docsUrl) {
|
|
4
|
+
if (cache && cache.docsUrl === docsUrl) {
|
|
5
|
+
return cache.docs;
|
|
6
|
+
}
|
|
7
|
+
const body = await getJson({ baseUrl: docsUrl, path: "/search-index.json" });
|
|
8
|
+
if (!body ||
|
|
9
|
+
typeof body !== "object" ||
|
|
10
|
+
!Array.isArray(body.docs)) {
|
|
11
|
+
throw new MafdetClientError("bad_response", "the docs search index is malformed");
|
|
12
|
+
}
|
|
13
|
+
const docs = body.docs.filter((doc) => doc &&
|
|
14
|
+
typeof doc.title === "string" &&
|
|
15
|
+
typeof doc.url === "string" &&
|
|
16
|
+
typeof doc.text === "string");
|
|
17
|
+
cache = { docsUrl, docs };
|
|
18
|
+
return docs;
|
|
19
|
+
}
|
|
20
|
+
export async function searchDocs(input) {
|
|
21
|
+
const docs = await loadIndex(input.docsUrl);
|
|
22
|
+
const needle = input.query.trim().toLowerCase();
|
|
23
|
+
if (!needle) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const base = input.docsBaseForUrl ?? input.docsUrl;
|
|
27
|
+
const scored = [];
|
|
28
|
+
for (const doc of docs) {
|
|
29
|
+
const title = doc.title.toLowerCase();
|
|
30
|
+
const text = doc.text.toLowerCase();
|
|
31
|
+
// Title matches rank above body matches; skip non-matches entirely.
|
|
32
|
+
let score = 0;
|
|
33
|
+
if (title.includes(needle))
|
|
34
|
+
score += 2;
|
|
35
|
+
if (text.includes(needle))
|
|
36
|
+
score += 1;
|
|
37
|
+
if (score > 0) {
|
|
38
|
+
scored.push({ doc, score });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Stable ordering: score desc, then id asc (docs are pre-sorted by id).
|
|
42
|
+
scored.sort((a, b) => b.score - a.score || a.doc.id.localeCompare(b.doc.id));
|
|
43
|
+
return scored.slice(0, input.limit).map(({ doc }) => ({
|
|
44
|
+
title: doc.title,
|
|
45
|
+
url: new URL(doc.url, base).toString(),
|
|
46
|
+
summary: doc.summary,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
// Test seam: reset the in-process cache.
|
|
50
|
+
export function resetDocsCache() {
|
|
51
|
+
cache = undefined;
|
|
52
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { McpConfig } from "./config.js";
|
|
2
|
+
export interface ToolResult {
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
content: Array<{
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
}>;
|
|
8
|
+
isError?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function listModels(config: McpConfig, args?: {
|
|
11
|
+
provider?: string;
|
|
12
|
+
tier?: string;
|
|
13
|
+
}): Promise<ToolResult>;
|
|
14
|
+
export declare function getBalance(config: McpConfig): Promise<ToolResult>;
|
|
15
|
+
export declare function getUsageSummary(config: McpConfig, args?: {
|
|
16
|
+
days?: number;
|
|
17
|
+
}): Promise<ToolResult>;
|
|
18
|
+
export declare function getSubscription(config: McpConfig): Promise<ToolResult>;
|
|
19
|
+
export declare function searchDocsTool(config: McpConfig, args: {
|
|
20
|
+
query: string;
|
|
21
|
+
limit?: number;
|
|
22
|
+
}): Promise<ToolResult>;
|
|
23
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAW7C,MAAM,WAAW,UAAU;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/C,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAkCD,wBAAsB,UAAU,CAC9B,MAAM,EAAE,SAAS,EACjB,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9C,OAAO,CAAC,UAAU,CAAC,CAmCrB;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAiBvE;AAED,wBAAsB,eAAe,CACnC,MAAM,EAAE,SAAS,EACjB,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAC3B,OAAO,CAAC,UAAU,CAAC,CAmBrB;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CA+B5E;AAED,wBAAsB,cAAc,CAClC,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtC,OAAO,CAAC,UAAU,CAAC,CAkBrB"}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { getJson, MafdetClientError } from "./client.js";
|
|
2
|
+
import { searchDocs } from "./search-docs.js";
|
|
3
|
+
function ok(text) {
|
|
4
|
+
return { content: [{ type: "text", text }] };
|
|
5
|
+
}
|
|
6
|
+
function fail(text) {
|
|
7
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
8
|
+
}
|
|
9
|
+
// Turn any thrown client error into a readable tool error (never leaks bodies).
|
|
10
|
+
function fromError(error) {
|
|
11
|
+
if (error instanceof MafdetClientError) {
|
|
12
|
+
return fail(error.message);
|
|
13
|
+
}
|
|
14
|
+
return fail("unexpected error talking to Mafdet AI");
|
|
15
|
+
}
|
|
16
|
+
function requireKey(config) {
|
|
17
|
+
if (!config.apiKey) {
|
|
18
|
+
return fail("No MAFDET_API_KEY is set. Set it (a Mafdet AI key with the account:read scope) to use this tool. list_models and search_docs work without a key.");
|
|
19
|
+
}
|
|
20
|
+
return config.apiKey;
|
|
21
|
+
}
|
|
22
|
+
// micro-USD (integer string) -> "$0.28" style; keeps the raw value alongside.
|
|
23
|
+
function usd(microString) {
|
|
24
|
+
const micro = Number(microString ?? "0");
|
|
25
|
+
if (!Number.isFinite(micro))
|
|
26
|
+
return "$0.00";
|
|
27
|
+
return `$${(micro / 1_000_000).toFixed(micro % 10_000 === 0 ? 2 : 4)}`;
|
|
28
|
+
}
|
|
29
|
+
export async function listModels(config, args = {}) {
|
|
30
|
+
try {
|
|
31
|
+
const body = await getJson({ baseUrl: config.baseUrl, path: "/models" });
|
|
32
|
+
const rows = Array.isArray(body.data)
|
|
33
|
+
? body.data
|
|
34
|
+
: [];
|
|
35
|
+
const provider = args.provider?.toLowerCase();
|
|
36
|
+
const tier = args.tier?.toLowerCase();
|
|
37
|
+
const models = rows
|
|
38
|
+
.filter((m) => !provider || String(m.provider).toLowerCase() === provider)
|
|
39
|
+
.filter((m) => !tier || String(m.accessTier).toLowerCase() === tier)
|
|
40
|
+
.map((m) => ({
|
|
41
|
+
model_id: m.modelId,
|
|
42
|
+
display_name: m.displayName,
|
|
43
|
+
provider: m.provider,
|
|
44
|
+
tier: m.accessTier,
|
|
45
|
+
input_per_1m_micro_usd: String(m.inputPricePer1mMicroUsd ?? "0"),
|
|
46
|
+
output_per_1m_micro_usd: String(m.outputPricePer1mMicroUsd ?? "0"),
|
|
47
|
+
context_length: m.contextLength,
|
|
48
|
+
trial: Boolean(m.isTrialEnabled),
|
|
49
|
+
deprecated: Boolean(m.deprecated),
|
|
50
|
+
}));
|
|
51
|
+
if (models.length === 0) {
|
|
52
|
+
return ok("No models matched the filter.");
|
|
53
|
+
}
|
|
54
|
+
const lines = models.map((m) => `- ${m.model_id} (${m.provider}, ${m.tier})` +
|
|
55
|
+
`${m.trial ? " [trial]" : ""}${m.deprecated ? " [deprecated]" : ""}` +
|
|
56
|
+
` — in ${usd(m.input_per_1m_micro_usd)}/1M, out ${usd(m.output_per_1m_micro_usd)}/1M, ctx ${m.context_length}`);
|
|
57
|
+
return ok(`Mafdet AI models (${models.length}):\n${lines.join("\n")}`);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return fromError(error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function getBalance(config) {
|
|
64
|
+
const key = requireKey(config);
|
|
65
|
+
if (typeof key !== "string")
|
|
66
|
+
return key;
|
|
67
|
+
try {
|
|
68
|
+
const w = (await getJson({
|
|
69
|
+
baseUrl: config.baseUrl,
|
|
70
|
+
path: "/keyinfo/wallet",
|
|
71
|
+
authKey: key,
|
|
72
|
+
}));
|
|
73
|
+
const text = `Available: ${usd(w.available_micro_usd)} ` +
|
|
74
|
+
`(balance ${usd(w.balance_micro_usd)} + bonus ${usd(w.bonus_micro_usd)} ` +
|
|
75
|
+
`− frozen ${usd(w.frozen_micro_usd)}), anchored in ${String(w.currency_anchor).toUpperCase()}.`;
|
|
76
|
+
return ok(text);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return fromError(error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function getUsageSummary(config, args = {}) {
|
|
83
|
+
const key = requireKey(config);
|
|
84
|
+
if (typeof key !== "string")
|
|
85
|
+
return key;
|
|
86
|
+
const days = args.days ?? 7;
|
|
87
|
+
try {
|
|
88
|
+
const u = (await getJson({
|
|
89
|
+
baseUrl: config.baseUrl,
|
|
90
|
+
path: "/keyinfo/usage-summary",
|
|
91
|
+
query: { days },
|
|
92
|
+
authKey: key,
|
|
93
|
+
}));
|
|
94
|
+
const text = `Last ${u.days} day(s): ${u.count} request(s), ` +
|
|
95
|
+
`${u.input_tokens} in / ${u.output_tokens} out (${u.total_tokens} total tokens), ` +
|
|
96
|
+
`charge ${usd(String(u.charge_micro_usd))}.`;
|
|
97
|
+
return ok(text);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return fromError(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function getSubscription(config) {
|
|
104
|
+
const key = requireKey(config);
|
|
105
|
+
if (typeof key !== "string")
|
|
106
|
+
return key;
|
|
107
|
+
try {
|
|
108
|
+
const s = (await getJson({
|
|
109
|
+
baseUrl: config.baseUrl,
|
|
110
|
+
path: "/keyinfo/subscription",
|
|
111
|
+
authKey: key,
|
|
112
|
+
}));
|
|
113
|
+
const planLine = s.plan_name
|
|
114
|
+
? `Plan: ${s.plan_name} (${s.status ?? "unknown"})`
|
|
115
|
+
: "No active subscription (default entitlement).";
|
|
116
|
+
const pg = s.playground
|
|
117
|
+
? ` Playground credit: ${usd(s.playground.remaining_micro_usd)} left of ${usd(s.playground.allowance_micro_usd)}.`
|
|
118
|
+
: "";
|
|
119
|
+
const period = s.current_period_end
|
|
120
|
+
? ` Period ends ${s.current_period_end}.`
|
|
121
|
+
: "";
|
|
122
|
+
return ok(`${planLine}.${pg}${period}`);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return fromError(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export async function searchDocsTool(config, args) {
|
|
129
|
+
const limit = Math.min(Math.max(args.limit ?? 5, 1), 10);
|
|
130
|
+
try {
|
|
131
|
+
const hits = await searchDocs({
|
|
132
|
+
docsUrl: config.docsUrl,
|
|
133
|
+
query: args.query,
|
|
134
|
+
limit,
|
|
135
|
+
});
|
|
136
|
+
if (hits.length === 0) {
|
|
137
|
+
return ok(`No documentation matched "${args.query}".`);
|
|
138
|
+
}
|
|
139
|
+
const lines = hits.map((h) => `- ${h.title}\n ${h.url}\n ${h.summary}`);
|
|
140
|
+
return ok(`Documentation matches for "${args.query}" (${hits.length}):\n${lines.join("\n")}`);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return fromError(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mafdet/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mafdet AI MCP server — read-only account tools (models, balance, usage, subscription, docs) for Claude Code, Cursor, Codex, OpenCode, and Claude Desktop.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mafdet AI",
|
|
8
|
+
"homepage": "https://www.mafdet.ai",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"email": "support@mafdet.ai"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"model-context-protocol",
|
|
15
|
+
"mafdet",
|
|
16
|
+
"ai",
|
|
17
|
+
"llm",
|
|
18
|
+
"claude",
|
|
19
|
+
"cursor",
|
|
20
|
+
"codex",
|
|
21
|
+
"opencode"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"mafdet-mcp": "dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"mcpName": "ai.mafdet/mcp",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"lint": "prettier --check \"src/**/*.ts\" \"test/**/*.mjs\" package.json tsconfig.json",
|
|
39
|
+
"test": "pnpm run build && node --test \"test/**/*.test.mjs\"",
|
|
40
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
44
|
+
"zod": "^4.4.3"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^20.19.0",
|
|
48
|
+
"typescript": "6.0.3"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20"
|
|
52
|
+
}
|
|
53
|
+
}
|