@rog0x/mcp-string-tools 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/README.md +73 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +153 -0
- package/dist/tools/slug-generator.d.ts +14 -0
- package/dist/tools/slug-generator.js +85 -0
- package/dist/tools/string-analyzer.d.ts +21 -0
- package/dist/tools/string-analyzer.js +109 -0
- package/dist/tools/string-diff.d.ts +20 -0
- package/dist/tools/string-diff.js +130 -0
- package/dist/tools/string-transformer.d.ts +16 -0
- package/dist/tools/string-transformer.js +177 -0
- package/dist/tools/template-engine.d.ts +8 -0
- package/dist/tools/template-engine.js +109 -0
- package/package.json +20 -0
- package/src/index.ts +186 -0
- package/src/tools/slug-generator.ts +108 -0
- package/src/tools/string-analyzer.ts +135 -0
- package/src/tools/string-diff.ts +163 -0
- package/src/tools/string-transformer.ts +227 -0
- package/src/tools/template-engine.ts +149 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.transformString = transformString;
|
|
4
|
+
function removeDuplicateLines(text) {
|
|
5
|
+
const lines = text.split("\n");
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
const unique = [];
|
|
8
|
+
let removed = 0;
|
|
9
|
+
for (const line of lines) {
|
|
10
|
+
if (!seen.has(line)) {
|
|
11
|
+
seen.add(line);
|
|
12
|
+
unique.push(line);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
removed++;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
original: text,
|
|
20
|
+
transformed: unique.join("\n"),
|
|
21
|
+
operation: "remove_duplicate_lines",
|
|
22
|
+
details: { linesRemoved: removed, linesRemaining: unique.length },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function removeExtraWhitespace(text) {
|
|
26
|
+
const transformed = text
|
|
27
|
+
.replace(/[^\S\n]+/g, " ")
|
|
28
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
29
|
+
.replace(/^ +| +$/gm, "")
|
|
30
|
+
.trim();
|
|
31
|
+
return {
|
|
32
|
+
original: text,
|
|
33
|
+
transformed,
|
|
34
|
+
operation: "remove_extra_whitespace",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function extractEmails(text) {
|
|
38
|
+
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
39
|
+
const matches = text.match(emailRegex) || [];
|
|
40
|
+
return {
|
|
41
|
+
original: text,
|
|
42
|
+
transformed: matches.join("\n"),
|
|
43
|
+
operation: "extract_emails",
|
|
44
|
+
details: { count: matches.length, emails: matches },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function extractUrls(text) {
|
|
48
|
+
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g;
|
|
49
|
+
const matches = text.match(urlRegex) || [];
|
|
50
|
+
return {
|
|
51
|
+
original: text,
|
|
52
|
+
transformed: matches.join("\n"),
|
|
53
|
+
operation: "extract_urls",
|
|
54
|
+
details: { count: matches.length, urls: matches },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function extractPhoneNumbers(text) {
|
|
58
|
+
const phoneRegex = /(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}/g;
|
|
59
|
+
const matches = text.match(phoneRegex) || [];
|
|
60
|
+
return {
|
|
61
|
+
original: text,
|
|
62
|
+
transformed: matches.join("\n"),
|
|
63
|
+
operation: "extract_phone_numbers",
|
|
64
|
+
details: { count: matches.length, phoneNumbers: matches },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function maskSensitiveData(text, maskChar = "*") {
|
|
68
|
+
let transformed = text;
|
|
69
|
+
let maskedCount = 0;
|
|
70
|
+
// Mask emails: keep first 2 chars + domain
|
|
71
|
+
transformed = transformed.replace(/([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, (_match, local, domain) => {
|
|
72
|
+
maskedCount++;
|
|
73
|
+
const keep = local.slice(0, 2);
|
|
74
|
+
return keep + maskChar.repeat(Math.max(local.length - 2, 3)) + "@" + domain;
|
|
75
|
+
});
|
|
76
|
+
// Mask phone numbers: keep last 4
|
|
77
|
+
transformed = transformed.replace(/(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}/g, (match) => {
|
|
78
|
+
maskedCount++;
|
|
79
|
+
const digits = match.replace(/\D/g, "");
|
|
80
|
+
const last4 = digits.slice(-4);
|
|
81
|
+
return maskChar.repeat(digits.length - 4) + last4;
|
|
82
|
+
});
|
|
83
|
+
// Mask credit card numbers: keep last 4
|
|
84
|
+
transformed = transformed.replace(/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, (match) => {
|
|
85
|
+
maskedCount++;
|
|
86
|
+
const digits = match.replace(/\D/g, "");
|
|
87
|
+
const last4 = digits.slice(-4);
|
|
88
|
+
return maskChar.repeat(12) + last4;
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
original: text,
|
|
92
|
+
transformed,
|
|
93
|
+
operation: "mask_sensitive_data",
|
|
94
|
+
details: { maskedCount },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function truncateWithEllipsis(text, maxLength = 100, ellipsis = "...") {
|
|
98
|
+
if (text.length <= maxLength) {
|
|
99
|
+
return {
|
|
100
|
+
original: text,
|
|
101
|
+
transformed: text,
|
|
102
|
+
operation: "truncate",
|
|
103
|
+
details: { truncated: false },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const cutoff = maxLength - ellipsis.length;
|
|
107
|
+
// Try to cut at word boundary
|
|
108
|
+
const lastSpace = text.lastIndexOf(" ", cutoff);
|
|
109
|
+
const breakAt = lastSpace > cutoff * 0.7 ? lastSpace : cutoff;
|
|
110
|
+
return {
|
|
111
|
+
original: text,
|
|
112
|
+
transformed: text.slice(0, breakAt).trimEnd() + ellipsis,
|
|
113
|
+
operation: "truncate",
|
|
114
|
+
details: {
|
|
115
|
+
truncated: true,
|
|
116
|
+
originalLength: text.length,
|
|
117
|
+
newLength: breakAt + ellipsis.length,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function wrapText(text, width = 80) {
|
|
122
|
+
const lines = text.split("\n");
|
|
123
|
+
const wrapped = [];
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (line.length <= width) {
|
|
126
|
+
wrapped.push(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const words = line.split(" ");
|
|
130
|
+
let current = "";
|
|
131
|
+
for (const word of words) {
|
|
132
|
+
if (current.length === 0) {
|
|
133
|
+
current = word;
|
|
134
|
+
}
|
|
135
|
+
else if (current.length + 1 + word.length <= width) {
|
|
136
|
+
current += " " + word;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
wrapped.push(current);
|
|
140
|
+
current = word;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (current.length > 0) {
|
|
144
|
+
wrapped.push(current);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
original: text,
|
|
149
|
+
transformed: wrapped.join("\n"),
|
|
150
|
+
operation: "wrap_text",
|
|
151
|
+
details: { width, lineCount: wrapped.length },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function transformString(options) {
|
|
155
|
+
const { operation, text, maskChar, maxLength, wrapWidth, ellipsis } = options;
|
|
156
|
+
switch (operation) {
|
|
157
|
+
case "remove_duplicate_lines":
|
|
158
|
+
return removeDuplicateLines(text);
|
|
159
|
+
case "remove_extra_whitespace":
|
|
160
|
+
return removeExtraWhitespace(text);
|
|
161
|
+
case "extract_emails":
|
|
162
|
+
return extractEmails(text);
|
|
163
|
+
case "extract_urls":
|
|
164
|
+
return extractUrls(text);
|
|
165
|
+
case "extract_phone_numbers":
|
|
166
|
+
return extractPhoneNumbers(text);
|
|
167
|
+
case "mask_sensitive_data":
|
|
168
|
+
return maskSensitiveData(text, maskChar);
|
|
169
|
+
case "truncate":
|
|
170
|
+
return truncateWithEllipsis(text, maxLength, ellipsis);
|
|
171
|
+
case "wrap_text":
|
|
172
|
+
return wrapText(text, wrapWidth);
|
|
173
|
+
default:
|
|
174
|
+
throw new Error(`Unknown operation: ${operation}. Available: remove_duplicate_lines, remove_extra_whitespace, extract_emails, extract_urls, extract_phone_numbers, mask_sensitive_data, truncate, wrap_text`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=string-transformer.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface TemplateResult {
|
|
2
|
+
template: string;
|
|
3
|
+
rendered: string;
|
|
4
|
+
variablesUsed: string[];
|
|
5
|
+
variablesMissing: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function renderTemplate(template: string, data: Record<string, unknown>): TemplateResult;
|
|
8
|
+
//# sourceMappingURL=template-engine.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderTemplate = renderTemplate;
|
|
4
|
+
function getNestedValue(obj, path) {
|
|
5
|
+
const parts = path.split(".");
|
|
6
|
+
let current = obj;
|
|
7
|
+
for (const part of parts) {
|
|
8
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
current = current[part];
|
|
12
|
+
}
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
function isTruthy(value) {
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return value.length > 0;
|
|
18
|
+
if (typeof value === "object" && value !== null)
|
|
19
|
+
return Object.keys(value).length > 0;
|
|
20
|
+
return Boolean(value);
|
|
21
|
+
}
|
|
22
|
+
function processConditionals(template, data) {
|
|
23
|
+
// {{#if varName}}...content...{{/if}}
|
|
24
|
+
// {{#if varName}}...content...{{#else}}...alt...{{/if}}
|
|
25
|
+
const ifRegex = /\{\{#if\s+([^}]+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
|
26
|
+
let result = template;
|
|
27
|
+
let prevResult = "";
|
|
28
|
+
// Iterate until stable (handles nested conditionals)
|
|
29
|
+
while (result !== prevResult) {
|
|
30
|
+
prevResult = result;
|
|
31
|
+
result = result.replace(ifRegex, (_match, varName, body) => {
|
|
32
|
+
const trimmedVar = varName.trim();
|
|
33
|
+
const value = getNestedValue(data, trimmedVar);
|
|
34
|
+
const elseParts = body.split(/\{\{#else\}\}/);
|
|
35
|
+
const trueBranch = elseParts[0];
|
|
36
|
+
const falseBranch = elseParts.length > 1 ? elseParts[1] : "";
|
|
37
|
+
return isTruthy(value) ? trueBranch : falseBranch;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function processLoops(template, data) {
|
|
43
|
+
// {{#each items}}...{{this}}...{{@index}}...{{/each}}
|
|
44
|
+
const eachRegex = /\{\{#each\s+([^}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g;
|
|
45
|
+
let result = template;
|
|
46
|
+
let prevResult = "";
|
|
47
|
+
while (result !== prevResult) {
|
|
48
|
+
prevResult = result;
|
|
49
|
+
result = result.replace(eachRegex, (_match, varName, body) => {
|
|
50
|
+
const trimmedVar = varName.trim();
|
|
51
|
+
const value = getNestedValue(data, trimmedVar);
|
|
52
|
+
if (!Array.isArray(value)) {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
return value
|
|
56
|
+
.map((item, index) => {
|
|
57
|
+
let rendered = body;
|
|
58
|
+
// Replace {{this}} with current item
|
|
59
|
+
rendered = rendered.replace(/\{\{this\}\}/g, String(item));
|
|
60
|
+
// Replace {{@index}} with current index
|
|
61
|
+
rendered = rendered.replace(/\{\{@index\}\}/g, String(index));
|
|
62
|
+
// If item is object, allow {{this.prop}}
|
|
63
|
+
if (typeof item === "object" && item !== null) {
|
|
64
|
+
rendered = rendered.replace(/\{\{this\.([^}]+)\}\}/g, (_m, prop) => {
|
|
65
|
+
const val = item[prop.trim()];
|
|
66
|
+
return val !== undefined ? String(val) : "";
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return rendered;
|
|
70
|
+
})
|
|
71
|
+
.join("");
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function processVariables(template, data) {
|
|
77
|
+
const used = [];
|
|
78
|
+
const missing = [];
|
|
79
|
+
const rendered = template.replace(/\{\{([^#/][^}]*)\}\}/g, (_match, varName) => {
|
|
80
|
+
const trimmed = varName.trim();
|
|
81
|
+
// Skip special variables
|
|
82
|
+
if (trimmed === "this" || trimmed.startsWith("@") || trimmed.startsWith("this.")) {
|
|
83
|
+
return _match;
|
|
84
|
+
}
|
|
85
|
+
const value = getNestedValue(data, trimmed);
|
|
86
|
+
if (value !== undefined) {
|
|
87
|
+
used.push(trimmed);
|
|
88
|
+
return String(value);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
missing.push(trimmed);
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return { rendered, used, missing };
|
|
96
|
+
}
|
|
97
|
+
function renderTemplate(template, data) {
|
|
98
|
+
// Process in order: conditionals, loops, then variables
|
|
99
|
+
let result = processConditionals(template, data);
|
|
100
|
+
result = processLoops(result, data);
|
|
101
|
+
const { rendered, used, missing } = processVariables(result, data);
|
|
102
|
+
return {
|
|
103
|
+
template,
|
|
104
|
+
rendered,
|
|
105
|
+
variablesUsed: [...new Set(used)],
|
|
106
|
+
variablesMissing: [...new Set(missing)],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=template-engine.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rog0x/mcp-string-tools",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Advanced string manipulation tools for AI agents via MCP",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["mcp", "string", "tools", "ai", "agents"],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.12.1"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.7.3",
|
|
18
|
+
"@types/node": "^22.13.10"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { analyzeString } from "./tools/string-analyzer.js";
|
|
8
|
+
import { transformString } from "./tools/string-transformer.js";
|
|
9
|
+
import { diffStrings } from "./tools/string-diff.js";
|
|
10
|
+
import { renderTemplate } from "./tools/template-engine.js";
|
|
11
|
+
import { generateSlug } from "./tools/slug-generator.js";
|
|
12
|
+
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "mcp-string-tools",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Tool 1: String Analyzer
|
|
19
|
+
server.tool(
|
|
20
|
+
"analyze_string",
|
|
21
|
+
"Analyze text to get character count, word count, sentence count, paragraph count, reading time, Flesch-Kincaid reading level, unique words, most common words, and longest word.",
|
|
22
|
+
{
|
|
23
|
+
text: z.string().describe("The text to analyze"),
|
|
24
|
+
},
|
|
25
|
+
async ({ text }) => {
|
|
26
|
+
const result = analyzeString(text);
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Tool 2: String Transformer
|
|
34
|
+
server.tool(
|
|
35
|
+
"transform_string",
|
|
36
|
+
"Transform text with operations: remove_duplicate_lines, remove_extra_whitespace, extract_emails, extract_urls, extract_phone_numbers, mask_sensitive_data, truncate, wrap_text.",
|
|
37
|
+
{
|
|
38
|
+
text: z.string().describe("The text to transform"),
|
|
39
|
+
operation: z
|
|
40
|
+
.enum([
|
|
41
|
+
"remove_duplicate_lines",
|
|
42
|
+
"remove_extra_whitespace",
|
|
43
|
+
"extract_emails",
|
|
44
|
+
"extract_urls",
|
|
45
|
+
"extract_phone_numbers",
|
|
46
|
+
"mask_sensitive_data",
|
|
47
|
+
"truncate",
|
|
48
|
+
"wrap_text",
|
|
49
|
+
])
|
|
50
|
+
.describe("The transformation operation to apply"),
|
|
51
|
+
mask_char: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.default("*")
|
|
55
|
+
.describe("Character used for masking (mask_sensitive_data only)"),
|
|
56
|
+
max_length: z
|
|
57
|
+
.number()
|
|
58
|
+
.optional()
|
|
59
|
+
.default(100)
|
|
60
|
+
.describe("Maximum length for truncation (truncate only)"),
|
|
61
|
+
wrap_width: z
|
|
62
|
+
.number()
|
|
63
|
+
.optional()
|
|
64
|
+
.default(80)
|
|
65
|
+
.describe("Column width for text wrapping (wrap_text only)"),
|
|
66
|
+
ellipsis: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.default("...")
|
|
70
|
+
.describe("Ellipsis string for truncation (truncate only)"),
|
|
71
|
+
},
|
|
72
|
+
async ({ text, operation, mask_char, max_length, wrap_width, ellipsis }) => {
|
|
73
|
+
const result = transformString({
|
|
74
|
+
text,
|
|
75
|
+
operation,
|
|
76
|
+
maskChar: mask_char,
|
|
77
|
+
maxLength: max_length,
|
|
78
|
+
wrapWidth: wrap_width,
|
|
79
|
+
ellipsis,
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Tool 3: String Diff
|
|
88
|
+
server.tool(
|
|
89
|
+
"diff_strings",
|
|
90
|
+
"Compare two strings showing additions, deletions, and unchanged segments with positions. Supports character-level, word-level, and line-level diff modes.",
|
|
91
|
+
{
|
|
92
|
+
old_text: z.string().describe("The original text"),
|
|
93
|
+
new_text: z.string().describe("The new/modified text"),
|
|
94
|
+
mode: z
|
|
95
|
+
.enum(["character", "word", "line"])
|
|
96
|
+
.optional()
|
|
97
|
+
.default("word")
|
|
98
|
+
.describe("Diff granularity: character, word, or line"),
|
|
99
|
+
},
|
|
100
|
+
async ({ old_text, new_text, mode }) => {
|
|
101
|
+
const result = diffStrings(old_text, new_text, mode);
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Tool 4: Template Engine
|
|
109
|
+
server.tool(
|
|
110
|
+
"render_template",
|
|
111
|
+
"Render a template string by replacing {{variables}} with values from a data object. Supports {{#if var}}...{{#else}}...{{/if}} conditionals and {{#each arr}}...{{/each}} loops with {{this}}, {{@index}}, and {{this.prop}}.",
|
|
112
|
+
{
|
|
113
|
+
template: z.string().describe("Template string with {{variable}} placeholders"),
|
|
114
|
+
data: z
|
|
115
|
+
.string()
|
|
116
|
+
.describe(
|
|
117
|
+
"JSON string of key-value pairs to substitute into the template"
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
async ({ template, data }) => {
|
|
121
|
+
let parsedData: Record<string, unknown>;
|
|
122
|
+
try {
|
|
123
|
+
parsedData = JSON.parse(data);
|
|
124
|
+
} catch {
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: "text",
|
|
129
|
+
text: JSON.stringify({ error: "Invalid JSON in data parameter" }),
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
isError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const result = renderTemplate(template, parsedData);
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Tool 5: Slug Generator
|
|
143
|
+
server.tool(
|
|
144
|
+
"generate_slug",
|
|
145
|
+
"Generate a URL-friendly slug from text. Removes special characters, lowercases, replaces spaces with hyphens, transliterates accented characters. Configurable separator and max length.",
|
|
146
|
+
{
|
|
147
|
+
text: z.string().describe("The text to convert to a slug"),
|
|
148
|
+
separator: z
|
|
149
|
+
.string()
|
|
150
|
+
.optional()
|
|
151
|
+
.default("-")
|
|
152
|
+
.describe("Character to use as word separator (default: -)"),
|
|
153
|
+
max_length: z
|
|
154
|
+
.number()
|
|
155
|
+
.optional()
|
|
156
|
+
.default(0)
|
|
157
|
+
.describe("Maximum slug length (0 = unlimited)"),
|
|
158
|
+
lowercase: z
|
|
159
|
+
.boolean()
|
|
160
|
+
.optional()
|
|
161
|
+
.default(true)
|
|
162
|
+
.describe("Convert to lowercase (default: true)"),
|
|
163
|
+
},
|
|
164
|
+
async ({ text, separator, max_length, lowercase }) => {
|
|
165
|
+
const result = generateSlug({
|
|
166
|
+
text,
|
|
167
|
+
separator,
|
|
168
|
+
maxLength: max_length,
|
|
169
|
+
lowercase,
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
async function main(): Promise<void> {
|
|
178
|
+
const transport = new StdioServerTransport();
|
|
179
|
+
await server.connect(transport);
|
|
180
|
+
console.error("mcp-string-tools server running on stdio");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main().catch((err) => {
|
|
184
|
+
console.error("Fatal error:", err);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export interface SlugOptions {
|
|
2
|
+
text: string;
|
|
3
|
+
separator?: string;
|
|
4
|
+
maxLength?: number;
|
|
5
|
+
lowercase?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SlugResult {
|
|
9
|
+
original: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
length: number;
|
|
12
|
+
wasTruncated: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TRANSLITERATION_MAP: Record<string, string> = {
|
|
16
|
+
"\u00e0": "a", "\u00e1": "a", "\u00e2": "a", "\u00e3": "a", "\u00e4": "a", "\u00e5": "a",
|
|
17
|
+
"\u00e6": "ae",
|
|
18
|
+
"\u00e7": "c",
|
|
19
|
+
"\u00e8": "e", "\u00e9": "e", "\u00ea": "e", "\u00eb": "e",
|
|
20
|
+
"\u00ec": "i", "\u00ed": "i", "\u00ee": "i", "\u00ef": "i",
|
|
21
|
+
"\u00f0": "d",
|
|
22
|
+
"\u00f1": "n",
|
|
23
|
+
"\u00f2": "o", "\u00f3": "o", "\u00f4": "o", "\u00f5": "o", "\u00f6": "o", "\u00f8": "o",
|
|
24
|
+
"\u00f9": "u", "\u00fa": "u", "\u00fb": "u", "\u00fc": "u",
|
|
25
|
+
"\u00fd": "y", "\u00ff": "y",
|
|
26
|
+
"\u00fe": "th",
|
|
27
|
+
"\u00df": "ss",
|
|
28
|
+
"\u0100": "a", "\u0101": "a", "\u0102": "a", "\u0103": "a", "\u0104": "a", "\u0105": "a",
|
|
29
|
+
"\u0106": "c", "\u0107": "c", "\u0108": "c", "\u0109": "c", "\u010a": "c", "\u010b": "c", "\u010c": "c", "\u010d": "c",
|
|
30
|
+
"\u010e": "d", "\u010f": "d", "\u0110": "d", "\u0111": "d",
|
|
31
|
+
"\u0112": "e", "\u0113": "e", "\u0114": "e", "\u0115": "e", "\u0116": "e", "\u0117": "e", "\u0118": "e", "\u0119": "e", "\u011a": "e", "\u011b": "e",
|
|
32
|
+
"\u011c": "g", "\u011d": "g", "\u011e": "g", "\u011f": "g", "\u0120": "g", "\u0121": "g", "\u0122": "g", "\u0123": "g",
|
|
33
|
+
"\u0124": "h", "\u0125": "h", "\u0126": "h", "\u0127": "h",
|
|
34
|
+
"\u0128": "i", "\u0129": "i", "\u012a": "i", "\u012b": "i", "\u012c": "i", "\u012d": "i", "\u012e": "i", "\u012f": "i", "\u0130": "i", "\u0131": "i",
|
|
35
|
+
"\u0139": "l", "\u013a": "l", "\u013b": "l", "\u013c": "l", "\u013d": "l", "\u013e": "l", "\u0141": "l", "\u0142": "l",
|
|
36
|
+
"\u0143": "n", "\u0144": "n", "\u0145": "n", "\u0146": "n", "\u0147": "n", "\u0148": "n",
|
|
37
|
+
"\u014c": "o", "\u014d": "o", "\u014e": "o", "\u014f": "o", "\u0150": "o", "\u0151": "o", "\u0152": "oe", "\u0153": "oe",
|
|
38
|
+
"\u0154": "r", "\u0155": "r", "\u0156": "r", "\u0157": "r", "\u0158": "r", "\u0159": "r",
|
|
39
|
+
"\u015a": "s", "\u015b": "s", "\u015c": "s", "\u015d": "s", "\u015e": "s", "\u015f": "s", "\u0160": "s", "\u0161": "s",
|
|
40
|
+
"\u0162": "t", "\u0163": "t", "\u0164": "t", "\u0165": "t", "\u0166": "t", "\u0167": "t",
|
|
41
|
+
"\u0168": "u", "\u0169": "u", "\u016a": "u", "\u016b": "u", "\u016c": "u", "\u016d": "u", "\u016e": "u", "\u016f": "u", "\u0170": "u", "\u0171": "u", "\u0172": "u", "\u0173": "u",
|
|
42
|
+
"\u0174": "w", "\u0175": "w",
|
|
43
|
+
"\u0176": "y", "\u0177": "y", "\u0178": "y",
|
|
44
|
+
"\u0179": "z", "\u017a": "z", "\u017b": "z", "\u017c": "z", "\u017d": "z", "\u017e": "z",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function transliterate(text: string): string {
|
|
48
|
+
let result = "";
|
|
49
|
+
for (const char of text) {
|
|
50
|
+
const lower = char.toLowerCase();
|
|
51
|
+
if (TRANSLITERATION_MAP[lower]) {
|
|
52
|
+
const replacement = TRANSLITERATION_MAP[lower];
|
|
53
|
+
// Preserve case for first letter
|
|
54
|
+
if (char === char.toUpperCase() && char !== char.toLowerCase()) {
|
|
55
|
+
result += replacement.charAt(0).toUpperCase() + replacement.slice(1);
|
|
56
|
+
} else {
|
|
57
|
+
result += replacement;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
result += char;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function generateSlug(options: SlugOptions): SlugResult {
|
|
67
|
+
const {
|
|
68
|
+
text,
|
|
69
|
+
separator = "-",
|
|
70
|
+
maxLength = 0,
|
|
71
|
+
lowercase = true,
|
|
72
|
+
} = options;
|
|
73
|
+
|
|
74
|
+
let slug = transliterate(text);
|
|
75
|
+
|
|
76
|
+
if (lowercase) {
|
|
77
|
+
slug = slug.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Replace non-alphanumeric chars (except separator) with separator
|
|
81
|
+
const escapedSep = separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
82
|
+
slug = slug.replace(/[^a-zA-Z0-9]+/g, separator);
|
|
83
|
+
|
|
84
|
+
// Remove leading/trailing separators
|
|
85
|
+
const trimRegex = new RegExp(`^${escapedSep}+|${escapedSep}+$`, "g");
|
|
86
|
+
slug = slug.replace(trimRegex, "");
|
|
87
|
+
|
|
88
|
+
// Collapse multiple consecutive separators
|
|
89
|
+
const multiRegex = new RegExp(`${escapedSep}{2,}`, "g");
|
|
90
|
+
slug = slug.replace(multiRegex, separator);
|
|
91
|
+
|
|
92
|
+
let wasTruncated = false;
|
|
93
|
+
|
|
94
|
+
if (maxLength > 0 && slug.length > maxLength) {
|
|
95
|
+
wasTruncated = true;
|
|
96
|
+
slug = slug.slice(0, maxLength);
|
|
97
|
+
// Don't end on a separator
|
|
98
|
+
const endTrimRegex = new RegExp(`${escapedSep}+$`);
|
|
99
|
+
slug = slug.replace(endTrimRegex, "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
original: text,
|
|
104
|
+
slug,
|
|
105
|
+
length: slug.length,
|
|
106
|
+
wasTruncated,
|
|
107
|
+
};
|
|
108
|
+
}
|