safe-mcp-suite
safe-mcp-suite
两个 MCP 服务器 —— 一个终端和一个文件整理器 —— 共享同一个安全核心,两者都不允许绕过它。
这是什么
在 GitHub 上搜索一个运行 shell 命令的 MCP 服务器,你会一遍又一遍地看到同一个文件:一个用 @mcp.tool() 装饰的工具,一个对 subprocess.run(command, shell=True) 的调用,然后把结果直接交回给模型。文件系统类的也是同样的形状 —— 一个循环里的 os.rename,也许外面包一个 try/except。
它们能用。这就是问题所在。它们一直能用,直到模型产生出某个没人预料到的东西,而到那时删除已经发生了,而且没有任何记录说明运行了什么、为什么被允许。
这个仓库就是把这两个服务器重建,让安全就是设计本身,而不是事后加上的包装。一个单一的 safety/ 包拥有每一个可能伤害你的决策 —— 什么被允许、边界在哪里、什么被记录下来、什么被隐藏。底下的两个服务器只是接线。两者都无法越过核心,因为两者都没有实现自己的任何规则。
它背后的赌注是:确定性策略比模型判断更值得信赖。 提示词可以说服模型不再小心。它无法说服一个路径包含检查返回 True。
Related MCP server: Safe Terminal MCP Server
快速开始
你需要 Python 3.12+ 和 uv。
git clone https://github.com/Asaad-Suliman/safe-mcp-suite.git safe-mcp-suite
cd safe-mcp-suite
uv sync
./scripts/make_demo_sandbox.sh最后一个脚本会播种 sandbox/terminal、sandbox/files 和 state/,这样两个服务器都有合法的操作空间。然后启动你想要的那个:
uv run safe-mcp terminal --config policy.example.toml
uv run safe-mcp files --config policy.example.toml关于那个 --config 标志
它不是可选的,也没有回退。没有隐式的 ./policy.toml 查找,没有 .env 加载,没有悄悄指向你主目录的默认根目录。如果既没有设置 --config 也没有设置 SAFE_MCP_POLICY_FILE,服务器会打印原因然后退出。
这是故意的,也是整个设置中最有主见的一点。一个默默默认到某个方便位置的沙箱,总有一天会默默默认到某个昂贵的位置。拒绝启动是最便宜的失败方式。
仓库附带两个策略文件,它们不可互换:
文件 | 它是什么 | 会启动吗? |
| 可用的示例,根目录在 | 会 —— 现在就能运行 |
| 带注释的模板,根目录被注释掉了 | 不会,故意如此 |
policy.toml 拒绝启动,直到你自己填上 jail_root 和 workspace_root。这种拒绝是一个特性,而且有一个回归测试在维持它。复制它、编辑它、准备好后指向真实目录。
根目录也可以来自环境变量,如果你更喜欢的话:
SAFE_MCP_JAIL_ROOT=/path/to/jail
SAFE_MCP_WORKSPACE_ROOT=/path/to/workspace注册到 MCP 客户端
{
"mcpServers": {
"safe-mcp terminal": {
"command": "uv",
"args": ["run", "safe-mcp", "terminal"],
"env": {
"SAFE_MCP_POLICY_FILE": "/srv/safe-mcp/policy.example.toml",
"SAFE_MCP_JAIL_ROOT": "/srv/safe-mcp/sandbox"
}
},
"safe-mcp files": {
"command": "uv",
"args": ["run", "safe-mcp", "files"],
"env": {
"SAFE_MCP_POLICY_FILE": "/srv/safe-mcp/policy.example.toml",
"SAFE_MCP_WORKSPACE_ROOT": "/srv/safe-mcp/inbox"
}
}
}
}演示
下面的一切都是真实捕获的输出。这里没有任何手写、修剪或事后美化的内容 —— 这些是实时 MCP 客户端与两个服务器对话时返回的实际 OperationResult 信封,运行在 ./scripts/make_demo_sandbox.sh 播种的沙箱上,使用 policy.example.toml。
阅读每一个中的 code 字段。那个分类法就是全部要点:每一个结果,无论成功还是拒绝,都以相同的形状返回。
终端服务器
一个被允许的命令:
>>> tool: run_command args: {"command": "cat notes.txt"}
{
"action": "run_command",
"code": "OK",
"detail": {
"exit_code": 0,
"stderr": "",
"stdout": "demo file\n"
},
"duration_ms": 1,
"ok": true,
"reason": "'cat' is allowed"
}一个被拒绝的命令:
>>> tool: run_command args: {"command": "rm -rf /"}
{
"action": "run_command",
"code": "POLICY_DENIED",
"detail": {},
"duration_ms": 0,
"ok": false,
"reason": "'rm' is on the denylist"
}注意拒绝不是什么:不是回溯、不是抛出的异常、也不是模型必须猜测的字符串。它是一个带原因的类型化代码。
文件服务器
这个序列运行在一个播种的工作区上,其中包含普通文件、一个安装程序、一个点文件和一条符号链接 —— 策略区别对待的每一种东西各一个。
plan_organize 提议移动并列出跳过项:
>>> tool: plan_organize args: {}
{
"action": "plan_organize",
"code": "OK",
"detail": {
"created": "2026-08-07T16:49:00.048899+00:00",
"move_count": 2,
"moves": [
{
"category": "Images",
"dest": "Images/photo.png",
"size": 41,
"src": "photo.png"
},
{
"category": "Documents",
"dest": "Documents/report.pdf",
"size": 16,
"src": "report.pdf"
}
],
"plan_id": "7dcf2cff-399d-4af5-bb9a-a4131e1d5288",
"skip_count": 3,
"skips": [
{
"code": "NEEDS_EXPLICIT_REQUEST",
"name": ".bashrc",
"reason": "dotfiles are configuration, not clutter to be filed",
"rule": "organize"
},
{
"code": "POLICY_DENIED",
"name": "link.pdf",
"reason": "'organize' is not permitted by any rule (deny by default)",
"rule": null
},
{
"code": "NEEDS_EXPLICIT_REQUEST",
"name": "setup.exe",
"reason": "installers, executables and application folders are left where the user put them",
"rule": "organize"
}
],
"truncated": false
},
"duration_ms": 0,
"ok": true,
"reason": "proposed 2 move(s), skipped 3"
}apply_plan 执行同一个计划:
>>> tool: apply_plan args: {"plan_id": "7dcf2cff-399d-4af5-bb9a-a4131e1d5288"}
{
"action": "apply_plan",
"code": "OK",
"detail": {
"moved": 2,
"moves": [
{
"dest": "Images/photo.png",
"src": "photo.png"
},
{
"dest": "Documents/report.pdf",
"src": "report.pdf"
}
],
"plan_id": "7dcf2cff-399d-4af5-bb9a-a4131e1d5288",
"planned": 2
},
"duration_ms": 2,
"ok": true,
"reason": "moved 2 file(s)"
}现在是有趣的一对。同一个工具,两个具名目标,两个不同的答案。
move_file 被 PROTECTION 拒绝 —— 就是上面 plan_organize 跳过的同一条符号链接,被显式命名:
>>> tool: move_file args: {"src": "link.pdf", "dest": "Documents/link.pdf"}
{
"action": "move_file",
"code": "POLICY_DENIED",
"detail": {},
"duration_ms": 0,
"ok": false,
"reason": "'organize' is not permitted by any rule (deny by default)"
}move_file 成功移动了 plan_organize 以 NEEDS_EXPLICIT_REQUEST 推迟的安装程序,现在被显式命名:
>>> tool: move_file args: {"src": "setup.exe", "dest": "Documents/setup.exe"}
{
"action": "move_file",
"code": "OK",
"detail": {
"dest": "Documents/setup.exe",
"moved": 1,
"src": "setup.exe"
},
"duration_ms": 0,
"ok": true,
"reason": "moved setup.exe"
}规划器拒绝了两者。直接询问时,移动器拒绝了一个、做了另一个。这个差异不是不一致 —— 它是两层模型,下面有专门的一节讲它。
系统设计
服务器是接线,不是策略
两个服务器文件都不包含安全规则。每一条都存在于 safety/ 中,两个服务器都调用相同的函数来获得相同的答案。
flowchart TD
A["MCP client"] --> B["terminal server"]
A --> C["files server"]
B --> D["safety/policy.py<br/>allow or deny"]
C --> D
D --> E["safety/paths.py<br/>PathJail containment"]
E --> F["execute or move"]
F --> G["safety/redact.py<br/>secrets out, then truncate"]
G --> H["safety/audit.py<br/>append-only JSONL"]
H --> I["OperationResult"]
I --> A这不是为了整洁而整洁。它意味着一个安全修复只落在一个地方,也意味着审查这个仓库的审阅者读 safety/ 就够了。没有第二个实现藏在服务器模块里,悄悄与第一个漂移。
请求实际如何流动
解析。 命令字符串被拆分成 argv 列表。Shell 元字符在这里被拒绝,在任何东西被解释之前 —— 包括引号内的。最后一点是刻意保守的,它是一个记录在案的天花板,不是疏忽。
评估。 策略引擎回答允许或拒绝。拒绝永远获胜。任何未被明确允许的东西都被拒绝,所以一个空策略是一个无用的服务器,而不是一个开放的服务器。
包含。 每个路径都被解析并对照监狱根目录检查。符号链接作为链接被检查,永远不会被穿透。
执行。
subprocess.run使用shell=False、一个被擦洗的环境(恰好是PATH、HOME、LANG)、一个超时和一个输出上限。或者在文件侧,一个记录在日志中的受保护移动。脱敏,然后截断。 按这个顺序,总是。
记录。 追加到审计日志。如果那次写入失败,操作也随之失败。
六个值得偷走的决策
错误是数据,不是异常。 每一个结果都是一个带有类型化 ResultCode 的 OperationResult。没有回溯会到达客户端。收到堆栈跟踪的模型会试图绕过它;收到 POLICY_DENIED 的模型被告知了有用且明确的东西。
拒绝永远获胜。 允许规则和拒绝规则不会被加权或排序成平局决胜。如果拒绝匹配,答案就是否。参数规则按顺序无关的方式匹配令牌,所以重新排列标志不是绕过。
没有显式根目录就不启动。 在快速开始中讲过,但它也属于设计清单,因为"合理的默认值"是大多数沙箱获得第一次逃逸的方式。
审计日志被允许阻止你。 默认故障关闭:如果日志无法写入,操作就不会发生。你可以用 SAFE_MCP_AUDIT_FAIL_MODE 把它翻成故障开放,而那是你故意做出的决定,在一个配置文件里,有人能看到它。
先脱敏再截断。 把这两个反过来,一个 64KB 的输出上限可以把一个秘密切成两半并发出第一个片段,没有匹配任何模式。这是一个两行的排序选择,关闭了整整一类泄漏。
基于熵的秘密检测默认关闭。 它会对哈希、UUID 和 base64 负载不断触发。一个喊狼来了的脱敏器会被它自己的用户禁用,这比一个事先承认自己局限性的更糟。
PROTECTION 与 RESTRAINT
跳过规则分成两层,每条规则都声明自己属于哪一层。
PROTECTION 被每个工具强制执行,无一例外。其中只有一部分是声明的规则 —— unsafe-name 覆盖文件名中的控制字符。其余是结构性的:路径逃逸在 safety/paths.py 中失败包含检查,被占用的目标在 servers/files/apply.py 中失败 lstat 检查,符号链接根本不匹配任何允许规则,落入默认拒绝 —— 而 evaluate_layered 刻意将其归类为 PROTECTION。见下面的注释。无论哪种方式,都没有标志、没有覆盖、没有"我知道我在做什么"的参数。这些是不变量。
RESTRAINT 只由 plan_organize 强制执行。安装程序、应用程序文件夹、系统文件、隐藏文件、目录。这些并不危险 —— 它们是自动分类器不应该替你猜测的东西。规划器将它们报告为 NEEDS_EXPLICIT_REQUEST 然后继续。
结果就是上面的演示对。move_file 会移动你自己命名的安装程序,因为拒绝那是家长式作风,不是安全。它不会移动符号链接,无论你多么明确地要求,因为那是包含。
一个值得明确说明的细微差别:符号链接的拒绝原因读作
"'organize' is not permitted by any rule (deny by default)" 而不是
任何提到"符号链接"的东西。符号链接在任一层都不匹配任何允许规则,
所以它落入默认拒绝 —— 而 safety.policy.evaluate_layered
刻意将未匹配的默认拒绝回退归类为 PROTECTION,
作为对策略没有词汇描述的东西的最严格解读。通用措辞不是更弱的保证;上面 move_file 直接命名符号链接,就是证明。
一个我会在任何地方重复的实现细节:PROTECTION 集合是通过过滤完整规则集派生的,永远不会作为自己的列表组装。两个手工维护的列表会漂移,而这里漂移的失败模式是一条保护规则悄悄消失。过滤器不会忘记。
关于测试
661 个测试,全部封闭。没有测试写入真实状态:任何执行的东西都针对 tmp_path 运行,两个读取随附的 policy.toml 和 policy.example.toml 的套件会先把它们复制到 tmp_path。真实的审计日志或工作区永远不会被触碰。CI 在每次推送和拉取请求时运行套件加上 gitleaks 扫描。
这个数字在我添加测试的那一刻就会过时。重要的是隔离性,不是数量。
保证
每一行都指名执行它的模块和函数。如果这里的声明没有你可以打开的代码支持,它就不应该出现在表格里。
保证 | 执行方式 |
默认拒绝,两个服务器均如此 |
|
匹配的拒绝始终优先于匹配的允许 |
|
终端服务器中无 shell |
|
在任何策略检查之前拒绝 shell 元字符 |
|
命令被限制在单个目录内 |
|
文件移动被限制在单个工作区内,最终组件处符号链接安全 |
|
两层文件策略:安全不变量 vs 避免猜测 |
|
目标位置永远不会被静默覆盖 |
|
不存在删除工具,无论是否受保护 |
|
每次移动都可撤销,包括整个已应用的方案 |
|
已过期的方案不会移动任何文件 |
|
挂起的命令在截止时间被终止 |
|
输出在脱敏之后才被截断,绝不在此之前 |
|
子进程获得经过清理的环境 |
|
机密信息从输出和审计字段中一并脱敏 |
|
审计跟踪默认故障关闭 |
|
每次拒绝都被记录,并附有原因 |
|
威胁模型 — 明确不在范围内
你已加入白名单的危险命令。 如果你允许解释器或类 shell 工具(
bash、python、sh、find -exec、awk、env等),模型就能做该工具能做的任何事情。策略强度完全取决于操作者的白名单。内核/沙箱逃逸。 该限制是路径包含检查,而非内核沙箱 — 没有命名空间、cgroups 或 seccomp。参见"诚实说明"。
对状态文件的主机访问。 审计日志和撤销日志对服务器而言是防篡改的,但对任何拥有主机文件系统访问权限的人来说并非防篡改。
脱敏完整性。 基于模式且尽力而为;模式无法识别的机密形态会直接通过。
多租户身份或速率限制。 两个服务器内部均无按调用方进行的身份验证 — 信任边界是"谁能启动此进程",由 MCP 客户端通过启动它来执行,而非此代码。
验证路径与执行操作之间的竞态(TOCTOU)。 参见下方"诚实说明"。
与朴素的 MCP 服务器相比
许多快速的 MCP 服务器用 subprocess.run(cmd, shell=True) 包装终端访问,用 os.rename 包装文件移动。两者都方便但不安全。此表是事实陈述,并非声称完美安全。
关注点 | 朴素的 MCP 服务器 | safe-mcp-suite |
命令执行 |
| 仅 |
Shell 元字符 | 被解释( | 在任何策略检查之前被拒绝 |
文件移动 |
| 限制在单个工作区内;任一端出现符号链接均被拒绝,绝不跟随 |
覆盖文件 | 通常静默 — POSIX | 始终拒绝;绝不发明编号变体( |
撤销 | 无 | 每次移动都被记录; |
删除文件 | 通常存在,通常无保护 | 此服务器中不存在删除工具,仅此而已 |
提供给子进程的环境 | 完整的父环境,包含机密信息 | 清理为 |
输出或日志中的机密信息 | 直接通过 | 在截断之前脱敏,响应和审计跟踪中均如此 |
可审计性 | 默认无 | 追加式 JSONL,默认故障关闭 |
自动 vs 明确请求的操作 | 同一代码路径对两者一视同仁 |
|
两个工具均返回 OperationResult:
OperationResult {
ok: bool
code: ResultCode
action: str
reason: str
detail: dict # stdout, stderr, exit_code — empty when nothing ran
duration_ms: int
}run_command(command: str, cwd: str | None = None)— 根据策略评估command,若允许则沙箱运行。cwd可选,必须解析到限制根目录内;越界的遍历、符号链接或绝对路径返回PATH_ESCAPE且不运行任何内容。每次调用均被审计;在故障关闭审计下,日志不可写时返回AUDIT_UNAVAILABLE而非未记录地执行。非零退出码仍为ok: true— 命令已运行;是否成功是它自己的事。explain_command(command: str)— 干运行。与run_command走相同的解析和评估,在执行器之前返回,因此detail绝不携带stdout、stderr或退出码 — 未运行任何内容。
此服务器可返回的结果码:OK、POLICY_DENIED、INVALID_REQUEST、PATH_ESCAPE、TIMEOUT、OUTPUT_TRUNCATED、OPERATION_FAILED、AUDIT_UNAVAILABLE、INTERNAL_ERROR。
六个工具,这个列表就是设计本身 — 没有第七个,且没有一个会删除。
list_files(subdir: str | None = None)— 只读。报告每个条目的名称、大小、类别、整理器是否会移动它,以及不会移动时的原因。plan_organize()— 提出移动和跳过方案。不改变任何内容,连目标文件夹也不创建。返回一个plan_id以传给apply_plan。apply_plan(plan_id: str)— 执行方案。每个文件都先被重新检查;如果自规划以来有任何文件被更改、移动或消失,则整个方案被拒绝。一次性使用 — id 不可重放。move_file(src: str, dest: str)— 移动一个具名文件。dest是完整的目标路径,而非文件夹。目标已存在则被拒绝,绝不覆盖也绝不改名绕开。仅遵守 PROTECTION 规则 — 参见上文"两层模型"。undo_last_action()— 将最近一次移动或已应用的方案作为一个操作撤销。不会覆盖任何内容来为恢复的文件腾出空间。redo_last_action()— 重新应用最近被撤销的操作。一旦记录新工作,重做栈即清空。
此服务器可额外返回的结果码:NEEDS_EXPLICIT_REQUEST(通过了所有安全不变量,仅因未提示的行动会是猜测而被拒绝 — 直接指名目标并提出请求)。
一个文件 policy.toml,两个服务器均读取:
audit_log = "audit.jsonl" # shared
audit_fail_mode = "closed" # shared: "closed" or "open"
[redaction] # shared
enabled = true
entropy_fallback = false
extra_patterns = [] # [{ name = "...", regex = "..." }]
[terminal]
# jail_root = "/srv/safe-mcp/sandbox" # REQUIRED — here or via env
[terminal.limits]
timeout_seconds = 30
max_output_bytes = 65536
[terminal.allowlist]
commands = ["ls", "cat", "echo", "pwd", "git"]
[terminal.denylist]
commands = ["rm", "shutdown", "reboot", "curl", "wget", "chmod", "sudo"]
[[terminal.rules]]
command = "git"
deny_args = ["push --force", "push -f"]
reason = "force-push rewrites shared history"
[files]
# workspace_root = "/srv/safe-mcp/inbox" # REQUIRED — here or via env
journal = "organizer-journal.json"
max_plan_moves = 500
[files.categories]
Documents = [".pdf", ".doc", ".docx", "..."]
# ...
[[files.skip]]
layer = "protection" # or "restraint" — required, no default
when = ["unsafe-name"]
reason = "..."策略文件本身没有默认位置。用 --config 指向它:
safe-mcp terminal --config /path/to/policy.toml
safe-mcp files --config /path/to/policy.toml环境变量仍然受支持,且每个都会覆盖对应的 policy.toml 键。SAFE_MCP_POLICY_FILE 是值得特别指出的一个例外:它是 --config 的替代方案,而非对它的覆盖 — 两者同时给出时 --config 优先,两者均未给出时启动拒绝。
变量 | 含义 | 默认值 |
|
| 无 — 拒绝启动 |
| 终端沙箱目录(必填,此处或 | 无 — 拒绝启动 |
| 文件工作区目录(必填,此处或 | 无 — 拒绝启动 |
| 撤销/重做日志路径(必须位于工作区之外) |
|
| 共享审计跟踪路径(必须位于两个沙箱之外) |
|
|
|
|
启动失败时会大声报错 — 打印 fatal: 消息并以非零退出码退出 — 当出现以下情况时:完全没有提供策略文件路径(既没有 --config 也没有 SAFE_MCP_POLICY_FILE)、policy.toml 缺失或无效、沙箱/工作区根目录未设置或不是目录、操作员脱敏正则表达式无效、存在无标签的 [[files.skip]] 条目,或审计日志/日志文件位于它随后能够移动或伪造的沙箱内部。
坦诚的注意事项
这是一个加固层,不是保险库。在部署任一服务器之前,请先阅读以下内容。
沙箱是路径包含检查,不是内核沙箱。 没有命名空间、cgroups 或 seccomp。内核漏洞或可从白名单二进制文件触达的逃逸通道不受限制。
审计日志是可检测篡改的,不是防篡改的,并且没有轮换。 追加写入并逐条刷新 + fsync 意味着它不会因崩溃而丢失记录,但任何对主机文件系统有
audit.jsonl访问权限的人都可以读取、修改或删除它 — 而且文件会无限增长;没有内置轮换或保留策略。脱敏是基于模式的,尽力而为。 它能捕获常见的秘密形态;新颖或不寻常的格式会未经脱敏地通过。可选的熵回退默认关闭,因为它在 git SHA、UUID 和 base64 数据上会产生噪音,而不是因为它弱。
终端元字符即使在引号内也会被拒绝 — 这是一个已知的天花板。
echo "a;b"会被拒绝,即使;在引号内是惰性的,因为扫描是原始子字符串检查,不识别引号。这是安全的错误方向 — 没有任何引号技巧能让操作员绕过一开始就忽略引号的扫描 — 但这确实意味着一些合法输入会被拒绝。TOCTOU:先验证后操作的路径可能在两者之间发生变化。
safety/paths.py和servers/files/apply.py都先检查包含或占用,然后在单独的系统调用上操作;在该间隙中交换的符号链接或创建的文件不在覆盖范围内。在代码中记录为有意的、命名的天花板(两个文件中的# NOTE:注释),并注明了升级路径(O_NOFOLLOW加上 dir-fd 相对操作),以备将来需要。孙进程不会被回收。 被杀死或超时的命令自身的子进程不在单独的进程组中;执行器只杀死直接子进程,因此该命令产生的任何东西都可能比它活得更久。
相关工作
hardened-terminal-mcp — 本套件终端服务器的独立前身。
MCP-file-organizer — 本套件文件服务器的独立前身。
许可证
MIT — 见 LICENSE。
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
- Parallel Task MCPOAuth
An MCP server for deep research or task groups
Run commands and read/write files on your servers over Termalin's keyless tunnels (hosted MCP).
A MCP server built for developers enabling Git based project management with project and personal…
An MCP server that provides read access to your cloud storage providers, bank accounts and more.
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceA secure MCP server for shell operations, terminal management, and process control, enabling AI assistants to safely execute commands and manage interactive sessions.2046MIT
- FlicenseNot gradedqualityCmaintenanceA secure, controlled terminal MCP server that enables executing whitelisted shell commands safely with multiple security layers.
- AlicenseNot gradedqualityCmaintenanceA production-ready MCP server for secure, session-based command execution, file manipulation, and system inspection via local terminal sessions.14ISC
- AlicenseAqualityAmaintenanceSecurity-hardened MCP server that runs only allowlisted commands with no shell, jailed to a single directory, and bounded execution.2MIT
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Asaad-Suliman/safe-mcp-suite'
If you have feedback or need assistance with the MCP directory API, please join our Discord server