MCP server at POST /mcp (official SDK, stateless Streamable HTTP) (#11)
CI / build + unit (push) Successful in 1m6s
CI / build + unit (push) Successful in 1m6s
This commit was merged in pull request #11.
This commit is contained in:
+2
-1
@@ -9,7 +9,8 @@ import { AppModule } from './app.module.js';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
app.setGlobalPrefix('api');
|
||||
// 'mcp' is excluded so the MCP endpoint lives at /mcp, not /api/mcp (like Scalar at /)
|
||||
app.setGlobalPrefix('api', { exclude: ['mcp'] });
|
||||
|
||||
// Scalar API docs at / (OpenAPI JSON at /docs-json), outside the api prefix.
|
||||
const docConfig = new DocumentBuilder()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create, toBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import * as net from 'node:net';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { frame } from './codec.js';
|
||||
|
||||
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
|
||||
export function startBridge(
|
||||
onSocket?: (s: net.Socket) => void,
|
||||
): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> {
|
||||
const sockets: net.Socket[] = [];
|
||||
const server = net.createServer((socket) => {
|
||||
onSocket?.(socket); // attached at accept time, before any data can arrive
|
||||
sockets.push(socket);
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve({
|
||||
port: (server.address() as AddressInfo).port,
|
||||
sockets,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitFor(
|
||||
cond: () => boolean | Promise<boolean>,
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
while (!(await cond())) {
|
||||
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain-object NodeInfo fields (init shape, not the runtime message type) accepted by nodeInfoFrame. */
|
||||
export interface NodeInfoFields {
|
||||
user?: {
|
||||
id?: string;
|
||||
longName?: string;
|
||||
shortName?: string;
|
||||
hwModel?: number;
|
||||
};
|
||||
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
|
||||
lastHeard?: number;
|
||||
deviceMetrics?: { batteryLevel?: number };
|
||||
}
|
||||
|
||||
export function nodeInfoFrame(
|
||||
num: number,
|
||||
fields?: NodeInfoFields,
|
||||
): Uint8Array {
|
||||
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
|
||||
return frame(
|
||||
toBinary(
|
||||
P.Mesh.FromRadioSchema,
|
||||
create(P.Mesh.FromRadioSchema, {
|
||||
payloadVariant: { case: 'nodeInfo', value: ni },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const KAMIL_NUM = 123456789;
|
||||
|
||||
export function kamilNodeInfo(): NodeInfoFields {
|
||||
return {
|
||||
user: {
|
||||
id: '!kamil',
|
||||
longName: 'Kamil SP8MK/P',
|
||||
shortName: 'KK',
|
||||
hwModel: P.Mesh.HardwareModel.TBEAM,
|
||||
},
|
||||
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
|
||||
lastHeard: 1758000000,
|
||||
deviceMetrics: { batteryLevel: 87 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import type * as net from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BROADCAST_ADDR } from './codec.js';
|
||||
import {
|
||||
KAMIL_NUM,
|
||||
kamilNodeInfo,
|
||||
nodeInfoFrame,
|
||||
startBridge,
|
||||
waitFor,
|
||||
} from './fake-bridge.js';
|
||||
import { createMeshMcpServer } from './mcp.controller.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
// callTool returns CallToolResult | a legacy compatibility shape; we only ever get the former
|
||||
type ToolOutcome = { content: { type: string; text?: string }[] };
|
||||
|
||||
function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
|
||||
const c = (r as ToolOutcome).content[0];
|
||||
if (!c || c.type !== 'text') throw new Error('expected a text content block');
|
||||
return c.text ?? '';
|
||||
}
|
||||
|
||||
async function connectedService(): Promise<{
|
||||
svc: MeshService;
|
||||
sockets: net.Socket[];
|
||||
close: () => void;
|
||||
}> {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new MeshService();
|
||||
svc.onModuleInit();
|
||||
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected);
|
||||
return {
|
||||
svc,
|
||||
sockets: bridge.sockets,
|
||||
close: () => {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Drive the MCP server over an in-memory transport pair (no HTTP involved)
|
||||
async function connectMcp(svc: MeshService): Promise<{
|
||||
client: Client;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const server = createMeshMcpServer(svc);
|
||||
await server.connect(serverTransport);
|
||||
const client = new Client({ name: 'mesh-bot-test', version: '0.0.0' });
|
||||
await client.connect(clientTransport);
|
||||
return {
|
||||
client,
|
||||
close: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('MCP server tools', () => {
|
||||
it('lists all six tools with descriptions and input schemas', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const { tools } = await mcp.client.listTools();
|
||||
expect(tools.map((t) => t.name).sort()).toEqual([
|
||||
'get_messages',
|
||||
'get_node',
|
||||
'list_channels',
|
||||
'list_nodes',
|
||||
'mesh_status',
|
||||
'send_message',
|
||||
]);
|
||||
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
|
||||
|
||||
const send = tools.find((t) => t.name === 'send_message')!;
|
||||
expect(send.inputSchema.required).toEqual(['text']);
|
||||
expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([
|
||||
'text',
|
||||
'to',
|
||||
]);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('mesh_status returns the bridge connection state', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r = await mcp.client.callTool({
|
||||
name: 'mesh_status',
|
||||
arguments: {},
|
||||
});
|
||||
expect(r.isError).not.toBe(true);
|
||||
expect(JSON.parse(toolText(r))).toEqual({
|
||||
connected: true,
|
||||
myNodeNum: null,
|
||||
host: '127.0.0.1',
|
||||
port: Number(process.env.MESH_PORT),
|
||||
});
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('send_message broadcasts by default and resolves names to node numbers', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'hello' },
|
||||
});
|
||||
expect(r1.isError).not.toBe(true);
|
||||
const bcast = JSON.parse(toolText(r1)) as { id: number };
|
||||
expect(bcast.id).toBeGreaterThan(0);
|
||||
expect(env.svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: BROADCAST_ADDR,
|
||||
});
|
||||
|
||||
// name lookup via the node DB (unique substring)
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'dm', to: 'kamil' },
|
||||
});
|
||||
expect(r2.isError).not.toBe(true);
|
||||
expect(env.svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
});
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('send_message surfaces resolution errors as tool errors with the REST messages', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'x', to: 'zzz-nope' },
|
||||
});
|
||||
expect(r1.isError).toBe(true);
|
||||
expect(toolText(r1)).toContain("no node matching 'zzz-nope'");
|
||||
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'x', to: '0' },
|
||||
});
|
||||
expect(r2.isError).toBe(true);
|
||||
expect(toolText(r2)).toContain('invalid node number');
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('get_node resolves by name and errors when missing', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'get_node',
|
||||
arguments: { id: 'kamil' },
|
||||
});
|
||||
expect(r1.isError).not.toBe(true);
|
||||
expect(JSON.parse(toolText(r1))).toMatchObject({
|
||||
num: KAMIL_NUM,
|
||||
longName: 'Kamil SP8MK/P',
|
||||
shortName: 'KK',
|
||||
});
|
||||
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'get_node',
|
||||
arguments: { id: 'zzz' },
|
||||
});
|
||||
expect(r2.isError).toBe(true);
|
||||
expect(toolText(r2)).toContain("no node matching 'zzz'");
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('list_channels, list_nodes and get_messages return the service data', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
expect(
|
||||
JSON.parse(
|
||||
toolText(
|
||||
await mcp.client.callTool({
|
||||
name: 'list_channels',
|
||||
arguments: {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual([]); // no channel frames fed yet
|
||||
|
||||
const nodes = JSON.parse(
|
||||
toolText(
|
||||
await mcp.client.callTool({ name: 'list_nodes', arguments: {} }),
|
||||
),
|
||||
) as { num: number; longName?: string }[];
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].num).toBe(KAMIL_NUM);
|
||||
expect(nodes[0].longName).toBe('Kamil SP8MK/P');
|
||||
|
||||
await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'hi' },
|
||||
});
|
||||
const msgs = JSON.parse(
|
||||
toolText(
|
||||
await mcp.client.callTool({ name: 'get_messages', arguments: {} }),
|
||||
),
|
||||
) as { dir: string; to?: number }[];
|
||||
expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Controller, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
// The SDK's root entry is broken in all published 1.x tarballs (upstream #2273: the exports map points to a dist index that was never shipped) — use the documented subpath imports instead.
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import * as pkg from '../../package.json';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
const CHANNEL_NOTE =
|
||||
' All sends ride the pinned "test" channel (index 3) — never the primary channel.';
|
||||
|
||||
function jsonResult(value: unknown): {
|
||||
content: { type: 'text'; text: string }[];
|
||||
} {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
||||
}
|
||||
|
||||
/** Build an MCP server exposing the MeshService as tools (1:1 with the REST endpoints). */
|
||||
export function createMeshMcpServer(mesh: MeshService): McpServer {
|
||||
const server = new McpServer({ name: 'mesh-bot', version: pkg.version });
|
||||
|
||||
server.registerTool(
|
||||
'mesh_status',
|
||||
{
|
||||
description: `Bridge connection state: whether the bot is connected to the meshtastic bridge, this device's node number, and the bridge host/port.${CHANNEL_NOTE}`,
|
||||
},
|
||||
async () => jsonResult(mesh.status()),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'send_message',
|
||||
{
|
||||
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error.${CHANNEL_NOTE}`,
|
||||
inputSchema: { text: z.string(), to: z.string().optional() },
|
||||
},
|
||||
async ({ text, to }) =>
|
||||
jsonResult({ id: await mesh.sendText(await mesh.resolveDest(to), text) }),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_channels',
|
||||
{
|
||||
description: `The device's Meshtastic channels as { index, name, uplinkEnabled, downlinkEnabled }[] sorted by index (psk/keys redacted); empty until the initial sync completes.${CHANNEL_NOTE}`,
|
||||
},
|
||||
async () => jsonResult(mesh.channels()),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_nodes',
|
||||
{
|
||||
description: `Known mesh nodes as lean summaries sorted by node number; names/hwModel/batteryLevel appear only when the device reported them, lastHeard is epoch seconds (null = never heard), position only when both coordinates are known.${CHANNEL_NOTE}`,
|
||||
},
|
||||
async () => jsonResult(await mesh.nodes()),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_node',
|
||||
{
|
||||
description: `Look up one node by exact node number (number or numeric string), then case-insensitive exact long/short name, else the unique case-insensitive substring match. Errors when nothing or more than one node matches.${CHANNEL_NOTE}`,
|
||||
inputSchema: { id: z.string() },
|
||||
},
|
||||
async ({ id }) => {
|
||||
const n = await mesh.node(id);
|
||||
if (!n) throw new Error(`no node matching '${id}'`);
|
||||
return jsonResult(n);
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_messages',
|
||||
{
|
||||
description: `Message history, newest first (last 100): inbound text (dir "rx", with from) and our sends (dir "tx", with to + status pending→delivered/failed; broadcasts stay pending because firmware strips want_ack). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
|
||||
},
|
||||
async () => jsonResult(mesh.messages()),
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class McpController {
|
||||
constructor(private readonly mesh: MeshService) {}
|
||||
|
||||
// MCP Streamable HTTP endpoint (stateless mode): a fresh server + transport per request, outside the /api prefix.
|
||||
@Post('mcp')
|
||||
@ApiExcludeEndpoint()
|
||||
async mcp(@Req() req: Request, @Res() res: ServerResponse): Promise<void> {
|
||||
const server = createMeshMcpServer(this.mesh);
|
||||
try {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
res.on('close', () => {
|
||||
void transport.close();
|
||||
void server.close();
|
||||
});
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32603, message: String(error) },
|
||||
id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { McpController } from './mcp.controller.js';
|
||||
import { MeshController } from './mesh.controller.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MeshController],
|
||||
controllers: [MeshController, McpController],
|
||||
providers: [MeshService],
|
||||
})
|
||||
export class MeshModule {}
|
||||
|
||||
@@ -1,62 +1,20 @@
|
||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||
import * as P from '@meshtastic/protobufs';
|
||||
import * as net from 'node:net';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
||||
import {
|
||||
KAMIL_NUM,
|
||||
kamilNodeInfo,
|
||||
nodeInfoFrame,
|
||||
startBridge,
|
||||
waitFor,
|
||||
} from './fake-bridge.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
function startBridge(onSocket?: (s: net.Socket) => void): Promise<{
|
||||
port: number;
|
||||
sockets: net.Socket[];
|
||||
close: () => void;
|
||||
}> {
|
||||
const sockets: net.Socket[] = [];
|
||||
const server = net.createServer((socket) => {
|
||||
onSocket?.(socket); // attached at accept time, before any data can arrive
|
||||
sockets.push(socket);
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve({
|
||||
port: (server.address() as AddressInfo).port,
|
||||
sockets,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
cond: () => boolean | Promise<boolean>,
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
while (!(await cond())) {
|
||||
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function nodeInfoFrame(
|
||||
num: number,
|
||||
fields?: Partial<P.Mesh.NodeInfo>,
|
||||
): Uint8Array {
|
||||
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
|
||||
return frame(
|
||||
toBinary(
|
||||
P.Mesh.FromRadioSchema,
|
||||
create(P.Mesh.FromRadioSchema, {
|
||||
payloadVariant: { case: 'nodeInfo', value: ni },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// live position update: POSITION_APP packet with a serialized Position in Data.payload
|
||||
function positionPacketFrame(
|
||||
from: number,
|
||||
pos: Partial<P.Mesh.Position>,
|
||||
pos: { latitudeI?: number; longitudeI?: number },
|
||||
): Uint8Array {
|
||||
const data = create(P.Mesh.DataSchema, {
|
||||
portnum: P.Portnums.PortNum.POSITION_APP,
|
||||
@@ -80,7 +38,6 @@ function positionPacketFrame(
|
||||
);
|
||||
}
|
||||
|
||||
const KAMIL_NUM = 123456789;
|
||||
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
|
||||
function textPacketFrame(
|
||||
from: number,
|
||||
@@ -138,19 +95,6 @@ function routingFrame(
|
||||
),
|
||||
);
|
||||
}
|
||||
function kamilNodeInfo(): Partial<P.Mesh.NodeInfo> {
|
||||
return {
|
||||
user: {
|
||||
id: '!kamil',
|
||||
longName: 'Kamil SP8MK/P',
|
||||
shortName: 'KK',
|
||||
hwModel: P.Mesh.HardwareModel.TBEAM,
|
||||
},
|
||||
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
|
||||
lastHeard: 1758000000,
|
||||
deviceMetrics: { batteryLevel: 87 },
|
||||
};
|
||||
}
|
||||
|
||||
function channelFrame(
|
||||
index: number,
|
||||
|
||||
Reference in New Issue
Block a user