mikromcp 1.0.1 → 1.0.2
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 +1 -1
- package/dist/main.js +89 -62
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
|
|
14
14
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
|
|
15
|
-
[](package.json)
|
|
16
16
|
[](LICENSE)
|
|
17
17
|
[](package.json)
|
|
18
18
|
[](https://help.mikrotik.com/docs/display/ROS/REST+API)
|
package/dist/main.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/main.ts
|
|
4
4
|
import { config as loadDotenv } from "dotenv";
|
|
5
|
+
import { join as join3 } from "path";
|
|
6
|
+
import { homedir as homedir3 } from "os";
|
|
5
7
|
|
|
6
8
|
// src/cli/index.ts
|
|
7
9
|
import { Command } from "commander";
|
|
@@ -8604,7 +8606,7 @@ function createServerFactory(config, identityRegistry) {
|
|
|
8604
8606
|
const makeServer = () => {
|
|
8605
8607
|
const server = new McpServer({
|
|
8606
8608
|
name: "mikromcp",
|
|
8607
|
-
version: "1.0.
|
|
8609
|
+
version: "1.0.2"
|
|
8608
8610
|
});
|
|
8609
8611
|
registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry);
|
|
8610
8612
|
return server;
|
|
@@ -8927,7 +8929,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync6 } from "fs";
|
|
|
8927
8929
|
import { homedir } from "os";
|
|
8928
8930
|
import * as net from "net";
|
|
8929
8931
|
import chalk from "chalk";
|
|
8930
|
-
var localVersion = "1.0.
|
|
8932
|
+
var localVersion = "1.0.2";
|
|
8931
8933
|
var results = [];
|
|
8932
8934
|
function ok(msg) {
|
|
8933
8935
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -9211,6 +9213,12 @@ import { join as join2 } from "path";
|
|
|
9211
9213
|
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
|
|
9212
9214
|
import bcrypt2 from "bcryptjs";
|
|
9213
9215
|
import chalk2 from "chalk";
|
|
9216
|
+
function mikromcpDir() {
|
|
9217
|
+
return join2(homedir2(), ".mikromcp");
|
|
9218
|
+
}
|
|
9219
|
+
function defaultAuditLogPath() {
|
|
9220
|
+
return join2(mikromcpDir(), "audit.ndjson");
|
|
9221
|
+
}
|
|
9214
9222
|
function suggestEnvPrefix(routerId) {
|
|
9215
9223
|
return "ROUTER_" + routerId.toUpperCase().replace(/-/g, "_");
|
|
9216
9224
|
}
|
|
@@ -9228,17 +9236,6 @@ function findClaudeDesktopConfig() {
|
|
|
9228
9236
|
];
|
|
9229
9237
|
return candidates.find((p) => existsSync4(p)) ?? null;
|
|
9230
9238
|
}
|
|
9231
|
-
function appendToGitignore(projectRoot, entry) {
|
|
9232
|
-
const gitignorePath = join2(projectRoot, ".gitignore");
|
|
9233
|
-
if (existsSync4(gitignorePath)) {
|
|
9234
|
-
const content = readFileSync7(gitignorePath, "utf-8");
|
|
9235
|
-
if (!content.split("\n").some((line) => line.trim() === entry)) {
|
|
9236
|
-
writeFileSync2(gitignorePath, content.endsWith("\n") ? content + entry + "\n" : content + "\n" + entry + "\n");
|
|
9237
|
-
}
|
|
9238
|
-
} else {
|
|
9239
|
-
writeFileSync2(gitignorePath, entry + "\n");
|
|
9240
|
-
}
|
|
9241
|
-
}
|
|
9242
9239
|
async function collectRouterInfo() {
|
|
9243
9240
|
console.log(chalk2.bold("\n\u2500\u2500 Router configuration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
9244
9241
|
const routerId = await input({
|
|
@@ -9246,19 +9243,20 @@ async function collectRouterInfo() {
|
|
|
9246
9243
|
validate: validateRouterId
|
|
9247
9244
|
});
|
|
9248
9245
|
const host = await input({
|
|
9249
|
-
message: "Host / IP (e.g. 192.168.
|
|
9246
|
+
message: "Host / IP (e.g. 192.168.88.1):",
|
|
9250
9247
|
validate: (v) => v.trim().length > 0 ? true : "Host is required"
|
|
9251
9248
|
});
|
|
9252
9249
|
const portStr = await input({
|
|
9253
9250
|
message: "Port:",
|
|
9254
|
-
default: "
|
|
9251
|
+
default: "443",
|
|
9255
9252
|
validate: (v) => {
|
|
9256
9253
|
const n = Number(v);
|
|
9257
9254
|
return Number.isInteger(n) && n >= 1 && n <= 65535 ? true : "Port must be 1\u201365535";
|
|
9258
9255
|
}
|
|
9259
9256
|
});
|
|
9260
9257
|
const port = Number(portStr);
|
|
9261
|
-
const
|
|
9258
|
+
const tlsDefault = port === 443 || port === 8443;
|
|
9259
|
+
const tlsEnabled = await confirm({ message: "Enable TLS?", default: tlsDefault });
|
|
9262
9260
|
let rejectUnauthorized = true;
|
|
9263
9261
|
if (tlsEnabled) {
|
|
9264
9262
|
rejectUnauthorized = await confirm({
|
|
@@ -9345,16 +9343,18 @@ async function collectIdentityInfo() {
|
|
|
9345
9343
|
tokenHash
|
|
9346
9344
|
};
|
|
9347
9345
|
}
|
|
9348
|
-
async function collectEnvPreference(
|
|
9349
|
-
|
|
9350
|
-
|
|
9346
|
+
async function collectEnvPreference() {
|
|
9347
|
+
return confirm({
|
|
9348
|
+
message: "Write credentials to ~/.mikromcp/.env? (No = inject env vars at runtime)",
|
|
9349
|
+
default: true
|
|
9350
|
+
});
|
|
9351
9351
|
}
|
|
9352
9352
|
async function collectClaudeDesktopPreference() {
|
|
9353
9353
|
console.log(chalk2.bold("\n\u2500\u2500 Claude Desktop integration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
9354
9354
|
return confirm({ message: "Register with Claude Desktop?", default: true });
|
|
9355
9355
|
}
|
|
9356
|
-
function writeRoutersYaml(data
|
|
9357
|
-
const filePath = join2(configDir, "routers.yaml");
|
|
9356
|
+
function writeRoutersYaml(data) {
|
|
9357
|
+
const filePath = join2(data.configDir, "routers.yaml");
|
|
9358
9358
|
const newEntry = {
|
|
9359
9359
|
host: data.host,
|
|
9360
9360
|
port: data.port,
|
|
@@ -9376,12 +9376,12 @@ function writeRoutersYaml(data, configDir) {
|
|
|
9376
9376
|
if (!parsed.routers) parsed.routers = {};
|
|
9377
9377
|
}
|
|
9378
9378
|
parsed.routers[data.routerId] = newEntry;
|
|
9379
|
-
mkdirSync3(configDir, { recursive: true });
|
|
9379
|
+
mkdirSync3(data.configDir, { recursive: true });
|
|
9380
9380
|
writeFileSync2(filePath, yamlStringify(parsed, { lineWidth: 0 }));
|
|
9381
9381
|
return true;
|
|
9382
9382
|
}
|
|
9383
|
-
function writeIdentitiesYaml(data
|
|
9384
|
-
const filePath = join2(configDir, "identities.yaml");
|
|
9383
|
+
function writeIdentitiesYaml(data) {
|
|
9384
|
+
const filePath = join2(data.configDir, "identities.yaml");
|
|
9385
9385
|
const newEntry = {
|
|
9386
9386
|
token: data.tokenHash,
|
|
9387
9387
|
role: data.role,
|
|
@@ -9395,32 +9395,55 @@ function writeIdentitiesYaml(data, configDir) {
|
|
|
9395
9395
|
if (!parsed.identities) parsed.identities = {};
|
|
9396
9396
|
}
|
|
9397
9397
|
parsed.identities[data.identityId] = newEntry;
|
|
9398
|
-
mkdirSync3(configDir, { recursive: true });
|
|
9398
|
+
mkdirSync3(data.configDir, { recursive: true });
|
|
9399
9399
|
writeFileSync2(filePath, yamlStringify(parsed, { lineWidth: 0 }));
|
|
9400
9400
|
return true;
|
|
9401
9401
|
}
|
|
9402
|
-
function writeDotEnv(data
|
|
9403
|
-
const envPath = join2(projectRoot, ".env");
|
|
9404
|
-
const lines = [];
|
|
9405
|
-
lines.push(`ROUTER_${data.envPrefix.replace(/^ROUTER_/, "")}_USER=`);
|
|
9406
|
-
lines.push(`ROUTER_${data.envPrefix.replace(/^ROUTER_/, "")}_PASS=`);
|
|
9402
|
+
function writeDotEnv(data) {
|
|
9407
9403
|
const userKey = `${data.envPrefix}_USER`;
|
|
9408
9404
|
const passKey = `${data.envPrefix}_PASS`;
|
|
9409
|
-
|
|
9410
|
-
|
|
9411
|
-
|
|
9412
|
-
|
|
9413
|
-
|
|
9414
|
-
|
|
9415
|
-
|
|
9416
|
-
|
|
9405
|
+
const auditLogPath = defaultAuditLogPath();
|
|
9406
|
+
const transport2 = data.createIdentity ? "http" : "stdio";
|
|
9407
|
+
const confirmationSecret = data.createIdentity ? randomBytes(32).toString("hex") : "";
|
|
9408
|
+
const lines = [
|
|
9409
|
+
"# \u2500\u2500 Router credentials \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9410
|
+
`${userKey}=`,
|
|
9411
|
+
`${passKey}=`,
|
|
9412
|
+
"",
|
|
9413
|
+
"# \u2500\u2500 Transport \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9414
|
+
`# stdio \u2192 launched directly by Claude Desktop or another MCP client`,
|
|
9415
|
+
`# http \u2192 standalone server mode with bearer-token auth`,
|
|
9416
|
+
`MIKROMCP_TRANSPORT=${transport2}`,
|
|
9417
|
+
"",
|
|
9418
|
+
"# \u2500\u2500 Config paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9419
|
+
`MIKROMCP_CONFIG_PATH=${join2(data.configDir, "routers.yaml")}`,
|
|
9420
|
+
`MIKROMCP_IDENTITIES_PATH=${join2(data.configDir, "identities.yaml")}`,
|
|
9421
|
+
"",
|
|
9422
|
+
"# \u2500\u2500 Logging \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9423
|
+
"# Levels: trace | debug | info | warn | error",
|
|
9424
|
+
"MIKROMCP_LOG_LEVEL=info",
|
|
9425
|
+
`MIKROMCP_AUDIT_LOG_PATH=${auditLogPath}`,
|
|
9426
|
+
"",
|
|
9427
|
+
"# \u2500\u2500 HTTP transport (only used when MIKROMCP_TRANSPORT=http) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9428
|
+
"MIKROMCP_PORT=3000",
|
|
9429
|
+
`MIKROMCP_CONFIRMATION_SECRET=${confirmationSecret}`,
|
|
9430
|
+
"",
|
|
9431
|
+
"# \u2500\u2500 RBAC (optional, for stdio with identity enforcement) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
9432
|
+
"# MIKROMCP_STDIO_IDENTITY="
|
|
9433
|
+
];
|
|
9434
|
+
const content = lines.join("\n") + "\n";
|
|
9435
|
+
mkdirSync3(data.configDir, { recursive: true });
|
|
9436
|
+
if (existsSync4(data.envPath)) {
|
|
9437
|
+
const existing = readFileSync7(data.envPath, "utf-8");
|
|
9438
|
+
writeFileSync2(data.envPath, existing.endsWith("\n") ? existing + "\n" + content : existing + "\n\n" + content);
|
|
9417
9439
|
} else {
|
|
9418
|
-
writeFileSync2(envPath,
|
|
9440
|
+
writeFileSync2(data.envPath, content);
|
|
9419
9441
|
}
|
|
9420
|
-
|
|
9421
|
-
|
|
9442
|
+
console.log(
|
|
9443
|
+
chalk2.dim(` Fill in ${userKey} and ${passKey} in ${data.envPath}`)
|
|
9444
|
+
);
|
|
9422
9445
|
}
|
|
9423
|
-
function registerClaudeDesktop(
|
|
9446
|
+
function registerClaudeDesktop() {
|
|
9424
9447
|
const configPath = findClaudeDesktopConfig();
|
|
9425
9448
|
const entry = {
|
|
9426
9449
|
command: "mikromcp",
|
|
@@ -9452,17 +9475,17 @@ function registerClaudeDesktop(_projectRoot) {
|
|
|
9452
9475
|
}
|
|
9453
9476
|
async function runInit() {
|
|
9454
9477
|
console.log(chalk2.bold.cyan("\n\u{1F527} MikroMCP \u2014 Interactive Setup Wizard\n"));
|
|
9455
|
-
const projectRoot = process.cwd();
|
|
9456
|
-
const configDir = join2(projectRoot, "config");
|
|
9457
9478
|
const routerInfo = await collectRouterInfo();
|
|
9458
9479
|
const identityInfo = await collectIdentityInfo();
|
|
9459
|
-
const
|
|
9480
|
+
const configDir = mikromcpDir();
|
|
9481
|
+
const envPath = join2(configDir, ".env");
|
|
9482
|
+
const writeEnv = await collectEnvPreference();
|
|
9460
9483
|
let writeRouters = true;
|
|
9461
9484
|
const routersYamlPath = join2(configDir, "routers.yaml");
|
|
9462
9485
|
if (existsSync4(routersYamlPath)) {
|
|
9463
9486
|
console.log(chalk2.bold("\n\u2500\u2500 Config files \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
9464
9487
|
writeRouters = await confirm({
|
|
9465
|
-
message: "
|
|
9488
|
+
message: "routers.yaml already exists. Add this router to it?",
|
|
9466
9489
|
default: true
|
|
9467
9490
|
});
|
|
9468
9491
|
}
|
|
@@ -9470,6 +9493,8 @@ async function runInit() {
|
|
|
9470
9493
|
const data = {
|
|
9471
9494
|
...routerInfo,
|
|
9472
9495
|
...identityInfo,
|
|
9496
|
+
envPath,
|
|
9497
|
+
configDir,
|
|
9473
9498
|
writeEnv,
|
|
9474
9499
|
writeRoutersYaml: writeRouters,
|
|
9475
9500
|
writeIdentitiesYaml: identityInfo.createIdentity,
|
|
@@ -9478,23 +9503,23 @@ async function runInit() {
|
|
|
9478
9503
|
console.log(chalk2.bold("\n\u2500\u2500 Writing files \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
9479
9504
|
const summary = [];
|
|
9480
9505
|
if (data.writeRoutersYaml) {
|
|
9481
|
-
writeRoutersYaml(data
|
|
9482
|
-
summary.push(`
|
|
9483
|
-
console.log(chalk2.green(
|
|
9506
|
+
writeRoutersYaml(data);
|
|
9507
|
+
summary.push(`routers.yaml written to ${data.configDir}`);
|
|
9508
|
+
console.log(chalk2.green(` \u2714 ${join2(data.configDir, "routers.yaml")}`));
|
|
9484
9509
|
}
|
|
9485
9510
|
if (data.writeIdentitiesYaml) {
|
|
9486
|
-
writeIdentitiesYaml(data
|
|
9487
|
-
summary.push(`
|
|
9488
|
-
console.log(chalk2.green(
|
|
9511
|
+
writeIdentitiesYaml(data);
|
|
9512
|
+
summary.push(`identities.yaml written to ${data.configDir}`);
|
|
9513
|
+
console.log(chalk2.green(` \u2714 ${join2(data.configDir, "identities.yaml")}`));
|
|
9489
9514
|
}
|
|
9490
9515
|
if (data.writeEnv) {
|
|
9491
|
-
writeDotEnv(data
|
|
9492
|
-
summary.push(`.env
|
|
9493
|
-
console.log(chalk2.green(
|
|
9516
|
+
writeDotEnv(data);
|
|
9517
|
+
summary.push(`.env written to ${data.envPath}`);
|
|
9518
|
+
console.log(chalk2.green(` \u2714 ${data.envPath}`));
|
|
9494
9519
|
}
|
|
9495
9520
|
let desktopRegistered = false;
|
|
9496
9521
|
if (data.registerClaudeDesktop) {
|
|
9497
|
-
const result = registerClaudeDesktop(
|
|
9522
|
+
const result = registerClaudeDesktop();
|
|
9498
9523
|
desktopRegistered = result.registered;
|
|
9499
9524
|
if (desktopRegistered) {
|
|
9500
9525
|
summary.push("Claude Desktop registered");
|
|
@@ -9506,22 +9531,24 @@ async function runInit() {
|
|
|
9506
9531
|
console.log(chalk2.green(` \u2705 ${item}`));
|
|
9507
9532
|
}
|
|
9508
9533
|
if (data.createIdentity && data.rawToken) {
|
|
9509
|
-
console.log(
|
|
9534
|
+
console.log(
|
|
9535
|
+
chalk2.bold.yellow(` \u2705 Bearer token: ${data.rawToken} (save this \u2014 it won't be shown again)`)
|
|
9536
|
+
);
|
|
9510
9537
|
}
|
|
9511
9538
|
console.log(chalk2.bold("\nNext steps:"));
|
|
9512
|
-
console.log(chalk2.dim(` 1.
|
|
9513
|
-
console.log(chalk2.dim(" 2. Run:
|
|
9514
|
-
console.log(chalk2.dim(" 3.
|
|
9539
|
+
console.log(chalk2.dim(` 1. Fill in router credentials in ${data.envPath}`));
|
|
9540
|
+
console.log(chalk2.dim(" 2. Run: mikromcp doctor"));
|
|
9541
|
+
console.log(chalk2.dim(" 3. Restart Claude Desktop to load the MCP server"));
|
|
9515
9542
|
console.log();
|
|
9516
9543
|
}
|
|
9517
9544
|
|
|
9518
9545
|
// src/cli/index.ts
|
|
9519
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.0.
|
|
9546
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.0.2");
|
|
9520
9547
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
9521
9548
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
9522
9549
|
program.command("init").description("Interactive setup wizard").action(runInit);
|
|
9523
9550
|
|
|
9524
9551
|
// src/main.ts
|
|
9525
|
-
loadDotenv({ override: false });
|
|
9552
|
+
loadDotenv({ path: join3(homedir3(), ".mikromcp", ".env"), override: false });
|
|
9526
9553
|
program.parse();
|
|
9527
9554
|
//# sourceMappingURL=main.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikromcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Production-grade MCP server for MikroTik RouterOS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"dev": "tsx watch src/main.ts",
|
|
20
20
|
"build": "tsup",
|
|
21
21
|
"clean": "rm -rf dist",
|
|
22
|
-
"test": "vitest run",
|
|
22
|
+
"test": "vitest run && tsc --noEmit && eslint src/",
|
|
23
23
|
"test:watch": "vitest",
|
|
24
24
|
"lint": "eslint src/",
|
|
25
25
|
"format": "prettier --write 'src/**/*.ts' 'test/**/*.ts'",
|