omnideck-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/index.js +394 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AAPD Studio
|
|
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,122 @@
|
|
|
1
|
+
# OmniDeck MCP
|
|
2
|
+
|
|
3
|
+
**Local, privacy-first tools for AI agents. No network calls, ever.**
|
|
4
|
+
|
|
5
|
+
Some tasks are awkward to hand to an AI agent because doing them normally means
|
|
6
|
+
pasting a secret into a website: decoding a JWT, hashing a password, scrubbing
|
|
7
|
+
a log file before it goes to an API. This server does those locally instead.
|
|
8
|
+
|
|
9
|
+
Every tool is pure computation inside your own process. The server opens no
|
|
10
|
+
sockets and makes no outbound requests — nothing you pass to it leaves your
|
|
11
|
+
machine.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g omnideck-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or run it without installing:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx omnideck-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configure
|
|
26
|
+
|
|
27
|
+
**Claude Desktop** — add to `claude_desktop_config.json`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"omnideck": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "omnideck-mcp"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Claude Code:**
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude mcp add omnideck -- npx -y omnideck-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Any MCP client that speaks stdio works the same way.
|
|
47
|
+
|
|
48
|
+
## Tools
|
|
49
|
+
|
|
50
|
+
### `redact_sensitive_data`
|
|
51
|
+
|
|
52
|
+
Masks emails, API keys, credit cards, IBANs, IP addresses and phone numbers.
|
|
53
|
+
Run it over logs, config files or user-pasted text **before** that content
|
|
54
|
+
reaches an external API or a shared transcript.
|
|
55
|
+
|
|
56
|
+
Patterns are applied most-specific-first, so a key like `sk-ant-…` is matched
|
|
57
|
+
whole rather than being chopped up by the generic phone-number pattern.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Contact me at john@example.com, key sk-ant-abcdefghij1234567890
|
|
61
|
+
→ Contact me at [EMAIL REDACTED], key [API KEY REDACTED]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Optional `categories` argument restricts which classes are masked.
|
|
65
|
+
|
|
66
|
+
### `decode_jwt`
|
|
67
|
+
|
|
68
|
+
Decodes a token's header and payload and reports whether it has expired. The
|
|
69
|
+
signature is **not** verified — no secret is needed, and none should be pasted
|
|
70
|
+
anywhere. A JWT is a live credential; this exists so you never have to paste
|
|
71
|
+
one into an online decoder.
|
|
72
|
+
|
|
73
|
+
### `estimate_llm_cost`
|
|
74
|
+
|
|
75
|
+
Compares what a single request would cost across eight GPT, Claude and Gemini
|
|
76
|
+
models, cheapest first. Pass `text` (tokens are counted exactly via
|
|
77
|
+
`gpt-tokenizer`) or pass `input_tokens` directly. Useful before committing to a
|
|
78
|
+
model for a large batch job.
|
|
79
|
+
|
|
80
|
+
List prices are current as of **2026-08** and are refreshed periodically —
|
|
81
|
+
check your provider's pricing page before relying on them for billing
|
|
82
|
+
decisions.
|
|
83
|
+
|
|
84
|
+
### `split_for_context`
|
|
85
|
+
|
|
86
|
+
Splits a long document into chunks that fit a token budget, cutting on
|
|
87
|
+
paragraph boundaries and falling back to sentence boundaries, so no chunk ends
|
|
88
|
+
mid-thought.
|
|
89
|
+
|
|
90
|
+
### `check_output_fidelity`
|
|
91
|
+
|
|
92
|
+
Flags names, numbers and dates that appear in an AI-generated summary but not
|
|
93
|
+
in the source it was based on — the usual shape of a fabricated detail.
|
|
94
|
+
|
|
95
|
+
This is a **lexical heuristic, not a fact-checker.** It catches invented
|
|
96
|
+
specifics, not faulty reasoning, and a correctly-rephrased term can be flagged.
|
|
97
|
+
Treat the output as a list of things to verify by hand.
|
|
98
|
+
|
|
99
|
+
### `hash_text`
|
|
100
|
+
|
|
101
|
+
MD5, SHA-1, SHA-256 or SHA-512 digest of a string, computed locally. For when
|
|
102
|
+
the input is a password or other private value that has no business going into
|
|
103
|
+
an online hash generator.
|
|
104
|
+
|
|
105
|
+
## Privacy
|
|
106
|
+
|
|
107
|
+
- No network requests. No telemetry. No analytics. No logging of your inputs.
|
|
108
|
+
- Three runtime dependencies: the MCP SDK, `zod`, and `gpt-tokenizer`.
|
|
109
|
+
- MIT licensed — read [`index.js`](./index.js) and verify all of the above; it
|
|
110
|
+
is a single readable file.
|
|
111
|
+
|
|
112
|
+
## Related
|
|
113
|
+
|
|
114
|
+
Full documentation for this server, in English and Spanish:
|
|
115
|
+
[omnideck.cc/mcp](https://omnideck.cc/mcp)
|
|
116
|
+
|
|
117
|
+
These tools also run as free browser-based utilities, alongside ~125 others, at
|
|
118
|
+
[omnideck.cc](https://omnideck.cc) — same principle, everything client-side.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT © AAPD Studio
|
package/index.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// OmniDeck MCP — privacy-first local tools for AI agents.
|
|
3
|
+
//
|
|
4
|
+
// Every tool here is pure local computation. This server makes NO network
|
|
5
|
+
// requests of any kind: nothing you pass to it leaves your machine. That is
|
|
6
|
+
// the entire point — these are the tasks people usually solve by pasting
|
|
7
|
+
// secrets, tokens and private documents into a random website.
|
|
8
|
+
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
// Exact tokenization when gpt-tokenizer is available, rough estimate otherwise.
|
|
15
|
+
// Wrapped so a missing/renamed module degrades instead of crashing the server.
|
|
16
|
+
let encodeFn = null;
|
|
17
|
+
try {
|
|
18
|
+
({ encode: encodeFn } = await import("gpt-tokenizer/model/o200k_base"));
|
|
19
|
+
} catch {
|
|
20
|
+
try {
|
|
21
|
+
({ encode: encodeFn } = await import("gpt-tokenizer"));
|
|
22
|
+
} catch {
|
|
23
|
+
encodeFn = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function countTokens(text) {
|
|
28
|
+
if (!text) return { tokens: 0, exact: Boolean(encodeFn) };
|
|
29
|
+
if (encodeFn) {
|
|
30
|
+
try {
|
|
31
|
+
return { tokens: encodeFn(text).length, exact: true };
|
|
32
|
+
} catch {
|
|
33
|
+
/* fall through to the estimate below */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { tokens: Math.ceil(text.length / 4), exact: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
40
|
+
|
|
41
|
+
const server = new McpServer({ name: "omnideck", version: "1.0.0" });
|
|
42
|
+
|
|
43
|
+
/* ---------------------------------------------------------------- redaction */
|
|
44
|
+
|
|
45
|
+
// Order matters: each pattern runs on the output of the previous one, so the
|
|
46
|
+
// more specific patterns (keys, cards, IBAN) must run before the generic phone
|
|
47
|
+
// pattern, or phone's shorter digit-run match fragments a key's trailing digits
|
|
48
|
+
// or a card number before those patterns see the full string. Phone stays last.
|
|
49
|
+
const REDACTION_CATEGORIES = [
|
|
50
|
+
{
|
|
51
|
+
id: "emails",
|
|
52
|
+
tag: "[EMAIL REDACTED]",
|
|
53
|
+
regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "api_keys",
|
|
57
|
+
tag: "[API KEY REDACTED]",
|
|
58
|
+
regex: /\b(sk-[a-zA-Z0-9_-]{10,}|sk-ant-[a-zA-Z0-9_-]{10,}|AIza[a-zA-Z0-9_-]{20,}|gh[oprsu]_[a-zA-Z0-9]{20,}|glpat-[a-zA-Z0-9_-]{15,}|xox[baprs]-[a-zA-Z0-9-]{10,}|AKIA[A-Z0-9]{12,}|eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,})\b/g
|
|
59
|
+
},
|
|
60
|
+
{ id: "credit_cards", tag: "[CARD REDACTED]", regex: /\b(?:\d[ -]?){13,19}\b/g },
|
|
61
|
+
{ id: "iban", tag: "[IBAN REDACTED]", regex: /\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/g },
|
|
62
|
+
{
|
|
63
|
+
id: "ip_addresses",
|
|
64
|
+
tag: "[IP REDACTED]",
|
|
65
|
+
regex: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\b/g
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "phone_numbers",
|
|
69
|
+
tag: "[PHONE REDACTED]",
|
|
70
|
+
regex: /(?:\+?\d{1,3}[\s.-]?)?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}\b/g
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const CATEGORY_IDS = REDACTION_CATEGORIES.map((c) => c.id);
|
|
75
|
+
|
|
76
|
+
server.registerTool(
|
|
77
|
+
"redact_sensitive_data",
|
|
78
|
+
{
|
|
79
|
+
title: "Redact sensitive data",
|
|
80
|
+
description:
|
|
81
|
+
"Mask emails, API keys, credit cards, IBANs, IP addresses and phone numbers in a block of text. Use this BEFORE sending user-supplied text, logs, config files or pasted documents to any external API, or before quoting them back in a shared transcript. Runs entirely locally — nothing is transmitted.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
text: z.string().describe("The text to scrub."),
|
|
84
|
+
categories: z
|
|
85
|
+
.array(z.enum(CATEGORY_IDS))
|
|
86
|
+
.optional()
|
|
87
|
+
.describe(`Which categories to mask. Defaults to all: ${CATEGORY_IDS.join(", ")}.`)
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async ({ text: input, categories }) => {
|
|
91
|
+
const active = categories?.length
|
|
92
|
+
? REDACTION_CATEGORIES.filter((c) => categories.includes(c.id))
|
|
93
|
+
: REDACTION_CATEGORIES;
|
|
94
|
+
|
|
95
|
+
let out = input;
|
|
96
|
+
const counts = {};
|
|
97
|
+
for (const cat of active) {
|
|
98
|
+
let n = 0;
|
|
99
|
+
out = out.replace(cat.regex, () => {
|
|
100
|
+
n++;
|
|
101
|
+
return cat.tag;
|
|
102
|
+
});
|
|
103
|
+
if (n > 0) counts[cat.id] = n;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const found = Object.entries(counts);
|
|
107
|
+
const summary = found.length
|
|
108
|
+
? found.map(([k, v]) => `${v} ${k}`).join(", ")
|
|
109
|
+
: "nothing matched the selected categories";
|
|
110
|
+
|
|
111
|
+
return text(`Masked: ${summary}\n\n--- REDACTED TEXT ---\n${out}`);
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/* --------------------------------------------------------------------- jwt */
|
|
116
|
+
|
|
117
|
+
function b64urlDecode(segment) {
|
|
118
|
+
const pad = segment.length % 4 === 0 ? "" : "=".repeat(4 - (segment.length % 4));
|
|
119
|
+
return Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
"decode_jwt",
|
|
124
|
+
{
|
|
125
|
+
title: "Decode a JWT",
|
|
126
|
+
description:
|
|
127
|
+
"Decode the header and payload of a JSON Web Token and report its expiry status. Does NOT verify the signature (no secret is involved, and none should be pasted anywhere). Use this instead of an online JWT decoder — a token is a live credential and should never be pasted into a website.",
|
|
128
|
+
inputSchema: { token: z.string().describe("The JWT, with or without a 'Bearer ' prefix.") }
|
|
129
|
+
},
|
|
130
|
+
async ({ token }) => {
|
|
131
|
+
const raw = token.trim().replace(/^Bearer\s+/i, "");
|
|
132
|
+
const parts = raw.split(".");
|
|
133
|
+
if (parts.length < 2) {
|
|
134
|
+
return text("Not a valid JWT: expected at least two dot-separated segments.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let header, payload;
|
|
138
|
+
try {
|
|
139
|
+
header = JSON.parse(b64urlDecode(parts[0]));
|
|
140
|
+
payload = JSON.parse(b64urlDecode(parts[1]));
|
|
141
|
+
} catch {
|
|
142
|
+
return text("Could not decode this token: the header or payload is not valid base64url JSON.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines = [
|
|
146
|
+
"Signature NOT verified (decode only).",
|
|
147
|
+
"",
|
|
148
|
+
`Header:\n${JSON.stringify(header, null, 2)}`,
|
|
149
|
+
"",
|
|
150
|
+
`Payload:\n${JSON.stringify(payload, null, 2)}`
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
if (typeof payload.exp === "number") {
|
|
154
|
+
const expiry = new Date(payload.exp * 1000);
|
|
155
|
+
const expired = Date.now() > payload.exp * 1000;
|
|
156
|
+
lines.push("", `Expires: ${expiry.toISOString()} — ${expired ? "EXPIRED" : "still valid"}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return text(lines.join("\n"));
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/* -------------------------------------------------------------------- cost */
|
|
164
|
+
|
|
165
|
+
// Update every 2-3 months. USD per 1,000,000 tokens, standard public list rates.
|
|
166
|
+
const PRICE_DATE = "2026-08";
|
|
167
|
+
const PRICES = [
|
|
168
|
+
{ label: "GPT-4o mini", input: 0.15, output: 0.6 },
|
|
169
|
+
{ label: "Gemini 2.0 Flash", input: 0.1, output: 0.4 },
|
|
170
|
+
{ label: "Claude Haiku 4.5", input: 0.8, output: 4.0 },
|
|
171
|
+
{ label: "GPT-4o", input: 2.5, output: 10.0 },
|
|
172
|
+
{ label: "Gemini 1.5 Pro", input: 1.25, output: 5.0 },
|
|
173
|
+
{ label: "Claude Sonnet 5", input: 3.0, output: 15.0 },
|
|
174
|
+
{ label: "GPT-4 Turbo", input: 10.0, output: 30.0 },
|
|
175
|
+
{ label: "Claude Opus 4", input: 15.0, output: 75.0 }
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
"estimate_llm_cost",
|
|
180
|
+
{
|
|
181
|
+
title: "Estimate and compare LLM cost",
|
|
182
|
+
description:
|
|
183
|
+
"Compare what one request would cost across GPT, Claude and Gemini models, sorted cheapest first. Pass the prompt text, or pass input_tokens directly if you already know the count. Useful for picking a model before running a large batch job.",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
text: z.string().optional().describe("The prompt text. Its tokens are counted for you."),
|
|
186
|
+
input_tokens: z
|
|
187
|
+
.number()
|
|
188
|
+
.int()
|
|
189
|
+
.nonnegative()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("Exact input token count, if you already have it. Overrides `text`."),
|
|
192
|
+
output_tokens: z
|
|
193
|
+
.number()
|
|
194
|
+
.int()
|
|
195
|
+
.nonnegative()
|
|
196
|
+
.default(500)
|
|
197
|
+
.describe("Expected output tokens. Defaults to 500.")
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
async ({ text: input, input_tokens, output_tokens }) => {
|
|
201
|
+
let inTokens;
|
|
202
|
+
let note;
|
|
203
|
+
if (typeof input_tokens === "number") {
|
|
204
|
+
inTokens = input_tokens;
|
|
205
|
+
note = "input tokens supplied by caller";
|
|
206
|
+
} else if (input) {
|
|
207
|
+
const counted = countTokens(input);
|
|
208
|
+
inTokens = counted.tokens;
|
|
209
|
+
note = counted.exact
|
|
210
|
+
? "input tokens counted exactly (o200k_base)"
|
|
211
|
+
: "input tokens ESTIMATED at ~4 chars/token — install gpt-tokenizer for exact counts";
|
|
212
|
+
} else {
|
|
213
|
+
return text("Provide either `text` or `input_tokens`.");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const rows = PRICES.map((m) => {
|
|
217
|
+
const inCost = (inTokens / 1e6) * m.input;
|
|
218
|
+
const outCost = (output_tokens / 1e6) * m.output;
|
|
219
|
+
return { label: m.label, inCost, outCost, total: inCost + outCost };
|
|
220
|
+
}).sort((a, b) => a.total - b.total);
|
|
221
|
+
|
|
222
|
+
const fmt = (n) => "$" + n.toFixed(6);
|
|
223
|
+
const body = rows
|
|
224
|
+
.map((r, i) => `${i === 0 ? "*" : " "} ${r.label.padEnd(20)} in ${fmt(r.inCost)} out ${fmt(r.outCost)} total ${fmt(r.total)}`)
|
|
225
|
+
.join("\n");
|
|
226
|
+
|
|
227
|
+
return text(
|
|
228
|
+
`Input: ${inTokens} tokens (${note})\nOutput: ${output_tokens} tokens\nList prices as of ${PRICE_DATE}\n\n${body}\n\n* cheapest`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
/* ------------------------------------------------------------------- split */
|
|
234
|
+
|
|
235
|
+
const CHARS_PER_TOKEN = 4;
|
|
236
|
+
|
|
237
|
+
// Split on paragraph boundaries; if a single paragraph exceeds the limit, fall
|
|
238
|
+
// back to splitting that paragraph at sentence boundaries.
|
|
239
|
+
function splitText(input, charLimit) {
|
|
240
|
+
const units = [];
|
|
241
|
+
for (const p of input.split(/\n\s*\n/)) {
|
|
242
|
+
if (p.length <= charLimit) {
|
|
243
|
+
units.push(p);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const sentences = p.match(/[^.!?]+[.!?]+(\s|$)|[^.!?]+$/g) || [p];
|
|
247
|
+
let buf = "";
|
|
248
|
+
for (const s of sentences) {
|
|
249
|
+
if ((buf + s).length > charLimit && buf) {
|
|
250
|
+
units.push(buf);
|
|
251
|
+
buf = s;
|
|
252
|
+
} else {
|
|
253
|
+
buf += s;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (buf) units.push(buf);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const chunks = [];
|
|
260
|
+
let current = "";
|
|
261
|
+
for (const u of units) {
|
|
262
|
+
const candidate = current ? current + "\n\n" + u : u;
|
|
263
|
+
if (candidate.length > charLimit && current) {
|
|
264
|
+
chunks.push(current);
|
|
265
|
+
current = u;
|
|
266
|
+
} else {
|
|
267
|
+
current = candidate;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (current) chunks.push(current);
|
|
271
|
+
return chunks;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
server.registerTool(
|
|
275
|
+
"split_for_context",
|
|
276
|
+
{
|
|
277
|
+
title: "Split text for a context window",
|
|
278
|
+
description:
|
|
279
|
+
"Break a long document into chunks that fit a target context window, cutting on paragraph boundaries (falling back to sentence boundaries) so no chunk ends mid-thought. Use when a file is too large to process in one pass.",
|
|
280
|
+
inputSchema: {
|
|
281
|
+
text: z.string().describe("The document to split."),
|
|
282
|
+
max_tokens: z
|
|
283
|
+
.number()
|
|
284
|
+
.int()
|
|
285
|
+
.positive()
|
|
286
|
+
.default(8000)
|
|
287
|
+
.describe("Approximate token budget per chunk. Defaults to 8000.")
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
async ({ text: input, max_tokens }) => {
|
|
291
|
+
const body = input.trim();
|
|
292
|
+
if (!body) return text("Nothing to split.");
|
|
293
|
+
|
|
294
|
+
const chunks = splitText(body, max_tokens * CHARS_PER_TOKEN);
|
|
295
|
+
const rendered = chunks
|
|
296
|
+
.map((c, i) => `--- PART ${i + 1} of ${chunks.length} (~${Math.round(c.length / CHARS_PER_TOKEN)} tokens) ---\n${c}`)
|
|
297
|
+
.join("\n\n");
|
|
298
|
+
|
|
299
|
+
return text(`Split into ${chunks.length} part(s).\n\n${rendered}`);
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
/* ---------------------------------------------------------------- fidelity */
|
|
304
|
+
|
|
305
|
+
const STOP_WORDS = /^(I|The|A|An|This|That|These|Those|It|We|They|He|She|You)$/;
|
|
306
|
+
|
|
307
|
+
function norm(s) {
|
|
308
|
+
return s.toLowerCase().replace(/[^\w\s]/g, "");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Pull out "salient" spans: numbers/percentages/dates, and runs of capitalized
|
|
312
|
+
// words that look like proper nouns — the parts most likely to be fabricated.
|
|
313
|
+
function extractSalientSpans(input) {
|
|
314
|
+
const spans = [];
|
|
315
|
+
let m;
|
|
316
|
+
const numberRe = /\b\d[\d,.]*%?\b/g;
|
|
317
|
+
while ((m = numberRe.exec(input))) spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
|
|
318
|
+
|
|
319
|
+
const properNounRe = /\b[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*\b/g;
|
|
320
|
+
while ((m = properNounRe.exec(input))) {
|
|
321
|
+
if (STOP_WORDS.test(m[0])) continue;
|
|
322
|
+
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
spans.sort((a, b) => a.start - b.start);
|
|
326
|
+
const merged = [];
|
|
327
|
+
for (const s of spans) {
|
|
328
|
+
const last = merged[merged.length - 1];
|
|
329
|
+
if (last && s.start <= last.end) {
|
|
330
|
+
last.end = Math.max(last.end, s.end);
|
|
331
|
+
last.text = input.slice(last.start, last.end);
|
|
332
|
+
} else {
|
|
333
|
+
merged.push({ ...s });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return merged;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
server.registerTool(
|
|
340
|
+
"check_output_fidelity",
|
|
341
|
+
{
|
|
342
|
+
title: "Check AI output against its source",
|
|
343
|
+
description:
|
|
344
|
+
"Flag names, numbers and dates that appear in an AI-generated summary but NOT in the source text it was based on — the usual shape of a hallucination. This is a lexical heuristic, not a fact-checker: it catches invented specifics, not wrong reasoning. Use it to decide what to verify by hand.",
|
|
345
|
+
inputSchema: {
|
|
346
|
+
original: z.string().describe("The source text the output was generated from."),
|
|
347
|
+
ai_output: z.string().describe("The AI-generated text to check.")
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
async ({ original, ai_output }) => {
|
|
351
|
+
if (!original.trim() || !ai_output.trim()) {
|
|
352
|
+
return text("Provide both `original` and `ai_output`.");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const originalNorm = norm(original);
|
|
356
|
+
const spans = extractSalientSpans(ai_output);
|
|
357
|
+
const flagged = spans.filter((s) => {
|
|
358
|
+
const n = norm(s.text);
|
|
359
|
+
return n.length > 0 && !originalNorm.includes(n);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (!flagged.length) {
|
|
363
|
+
return text(`Checked ${spans.length} salient term(s). All of them appear in the original text.`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const list = [...new Set(flagged.map((s) => s.text))].map((t) => ` - ${t}`).join("\n");
|
|
367
|
+
return text(
|
|
368
|
+
`${flagged.length} of ${spans.length} salient term(s) were NOT found in the original — verify these:\n\n${list}\n\nHeuristic only: a term can be legitimately rephrased and still be correct.`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
/* -------------------------------------------------------------------- hash */
|
|
374
|
+
|
|
375
|
+
server.registerTool(
|
|
376
|
+
"hash_text",
|
|
377
|
+
{
|
|
378
|
+
title: "Hash text locally",
|
|
379
|
+
description:
|
|
380
|
+
"Compute an MD5, SHA-1, SHA-256 or SHA-512 digest of a string, locally. Use instead of an online hash generator when the input is a password, secret or any private value.",
|
|
381
|
+
inputSchema: {
|
|
382
|
+
text: z.string().describe("The string to hash."),
|
|
383
|
+
algorithm: z.enum(["md5", "sha1", "sha256", "sha512"]).default("sha256").describe("Digest algorithm.")
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
async ({ text: input, algorithm }) => {
|
|
387
|
+
const digest = createHash(algorithm).update(input, "utf8").digest("hex");
|
|
388
|
+
return text(`${algorithm}: ${digest}`);
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
/* ------------------------------------------------------------------- start */
|
|
393
|
+
|
|
394
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omnideck-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Privacy-first local MCP server: redact secrets, decode JWTs, estimate LLM costs, split long documents and check AI output for hallucinations — all on your own machine, with no network calls.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"omnideck-mcp": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"modelcontextprotocol",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"claude",
|
|
23
|
+
"llm",
|
|
24
|
+
"ai",
|
|
25
|
+
"privacy",
|
|
26
|
+
"redaction",
|
|
27
|
+
"pii",
|
|
28
|
+
"secrets",
|
|
29
|
+
"jwt",
|
|
30
|
+
"tokenizer",
|
|
31
|
+
"llm-cost",
|
|
32
|
+
"hallucination"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": "AAPD Studio",
|
|
36
|
+
"homepage": "https://omnideck.cc",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/aapd-studio/omnideck-mcp.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/aapd-studio/omnideck-mcp/issues"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
46
|
+
"gpt-tokenizer": "^2.9.0",
|
|
47
|
+
"zod": "^3.23.8"
|
|
48
|
+
}
|
|
49
|
+
}
|