Build initial MCP SSH server
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
bin/
|
||||
obj/
|
||||
TestResults/
|
||||
artifacts/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.vscode/
|
||||
573
AGENTS.md
Normal file
573
AGENTS.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project
|
||||
|
||||
MCP SSH Server
|
||||
|
||||
Self-contained MCP server binary that provides SSH command execution, persistent terminal sessions, and file transfer support with minimal user configuration.
|
||||
|
||||
The implementation must rely on the user's existing OpenSSH configuration and SSH environment.
|
||||
|
||||
---
|
||||
|
||||
# Core Product Requirements
|
||||
|
||||
## Long-Term Goals
|
||||
|
||||
* SSH command execution
|
||||
* Persistent interactive terminal sessions
|
||||
* Remote file transfer support
|
||||
* Minimal user-side MCP configuration
|
||||
* Self-contained binary distribution
|
||||
* Use existing `~/.ssh/config`
|
||||
* Use existing SSH keys and SSH agent
|
||||
* Support SSH aliases exactly as users use them in terminal
|
||||
|
||||
## Current Vertical Slice
|
||||
|
||||
The first implementation pass intentionally narrows the MVP:
|
||||
|
||||
* .NET 10 stdio MCP server
|
||||
* `ssh_exec`
|
||||
* `terminal_start`, `terminal_write`, `terminal_read`, and `terminal_stop`
|
||||
* SSH.NET-based SSH connections
|
||||
* Tool-supplied `host`, `username`, optional `port`, optional `keyPath`, and optional `keyPassphrase`
|
||||
* Default key discovery from `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`, then `~/.ssh/id_rsa` when `keyPath` is omitted
|
||||
* No SSH agent, OpenSSH alias, `ssh -G`, ProxyJump, ProxyCommand, or SFTP support yet
|
||||
* Basic audit logging and timeout enforcement
|
||||
|
||||
## Non-Goals
|
||||
|
||||
* Do not replace OpenSSH
|
||||
* Do not store SSH private keys
|
||||
* Do not expose arbitrary local shell execution
|
||||
* Do not require duplicate SSH configuration inside MCP settings
|
||||
* Do not build a web UI for MVP
|
||||
|
||||
---
|
||||
|
||||
# Distribution Requirements
|
||||
|
||||
The server must ship as a single self-contained executable.
|
||||
|
||||
Target platforms:
|
||||
|
||||
* Windows x64
|
||||
* Linux x64
|
||||
* Linux arm64
|
||||
* macOS x64
|
||||
* macOS arm64
|
||||
|
||||
Expected binary names:
|
||||
|
||||
```text
|
||||
mcp-ssh
|
||||
mcp-ssh.exe
|
||||
```
|
||||
|
||||
Example .NET publish command:
|
||||
|
||||
```bash
|
||||
dotnet publish \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:PublishTrimmed=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# MCP Transport
|
||||
|
||||
Use stdio transport for MVP.
|
||||
|
||||
Example MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ssh": {
|
||||
"command": "/path/to/mcp-ssh",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No SSH-specific configuration should be required in MCP config.
|
||||
|
||||
---
|
||||
|
||||
# SSH Configuration Resolution
|
||||
|
||||
The long-term implementation should use OpenSSH configuration resolution.
|
||||
|
||||
Primary mechanism:
|
||||
|
||||
```bash
|
||||
ssh -G <host-alias>
|
||||
```
|
||||
|
||||
The implementation should parse the resolved output.
|
||||
|
||||
Required resolved fields:
|
||||
|
||||
* hostname
|
||||
* user
|
||||
* port
|
||||
* identityfile
|
||||
* proxyjump
|
||||
* proxycommand
|
||||
* stricthostkeychecking
|
||||
* userknownhostsfile
|
||||
* identitiesonly
|
||||
|
||||
The implementation must support:
|
||||
|
||||
* SSH aliases
|
||||
* bastion hosts
|
||||
* proxy jump
|
||||
* existing SSH agent
|
||||
* known hosts validation
|
||||
|
||||
Fallback parsing of `~/.ssh/config` may be implemented if `ssh -G` is unavailable.
|
||||
|
||||
This is not part of the current vertical slice. The current implementation treats `host` as a hostname or IP address supplied directly to the MCP tool.
|
||||
|
||||
---
|
||||
|
||||
# Authentication Requirements
|
||||
|
||||
Authentication should reuse the user's existing SSH environment.
|
||||
|
||||
Supported:
|
||||
|
||||
* SSH agent
|
||||
* identity files
|
||||
* OpenSSH config
|
||||
* known hosts
|
||||
* optional password prompt support
|
||||
|
||||
The implementation must never persist private keys.
|
||||
|
||||
---
|
||||
|
||||
# File Transfer Strategy
|
||||
|
||||
SFTP is the primary transfer mechanism.
|
||||
|
||||
Rationale:
|
||||
|
||||
* More stable across SSH implementations
|
||||
* Better metadata support
|
||||
* Better directory traversal semantics
|
||||
* Avoids SCP shell parsing issues
|
||||
* Modern OpenSSH increasingly routes SCP over SFTP internally
|
||||
|
||||
Priority order:
|
||||
|
||||
```text
|
||||
1. SFTP
|
||||
2. SCP fallback (optional future enhancement)
|
||||
```
|
||||
|
||||
The implementation must:
|
||||
|
||||
* Validate SFTP subsystem availability during connection
|
||||
* Return structured capability errors if unavailable
|
||||
* Not silently downgrade to SCP
|
||||
|
||||
Example error:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "sftp_unavailable",
|
||||
"message": "Remote host does not expose the SFTP subsystem.",
|
||||
"scpFallbackAvailable": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# MCP Tools
|
||||
|
||||
## ssh_exec
|
||||
|
||||
Execute a single SSH command.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "prod-api.example.com",
|
||||
"username": "deploy",
|
||||
"command": "systemctl status nginx",
|
||||
"cwd": "/var/www",
|
||||
"port": 22,
|
||||
"keyPath": "~/.ssh/id_ed25519",
|
||||
"keyPassphrase": "optional-passphrase",
|
||||
"timeoutSeconds": 30
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"exitCode": 0,
|
||||
"stdout": "...",
|
||||
"stderr": "...",
|
||||
"durationMs": 421
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
* Enforce timeout
|
||||
* Capture stdout separately
|
||||
* Capture stderr separately
|
||||
* Preserve non-zero exit codes
|
||||
* Authenticate with explicit `keyPath`, or the first available default private key from `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`, then `~/.ssh/id_rsa`
|
||||
* Support optional `keyPassphrase` for encrypted private keys
|
||||
|
||||
---
|
||||
|
||||
## terminal_start
|
||||
|
||||
Start a persistent PTY shell session.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "prod-api.example.com",
|
||||
"username": "deploy",
|
||||
"shell": "bash",
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"port": 22,
|
||||
"keyPath": "~/.ssh/id_ed25519",
|
||||
"keyPassphrase": "optional-passphrase",
|
||||
"idleTimeoutSeconds": 900
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "term_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
* Allocate PTY
|
||||
* Maintain server-side session state
|
||||
* Support idle timeout cleanup
|
||||
* Use the same key-auth inputs and default key discovery as `ssh_exec`
|
||||
|
||||
---
|
||||
|
||||
## terminal_write
|
||||
|
||||
Write to an active terminal session.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "term_abc123",
|
||||
"input": "tail -f /var/log/nginx/error.log\\n"
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## terminal_read
|
||||
|
||||
Read buffered output from a terminal session.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "term_abc123",
|
||||
"maxBytes": 12000
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": "...",
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## terminal_stop
|
||||
|
||||
Stop and remove a terminal session.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "term_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"stopped": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## sftp_list
|
||||
|
||||
List remote directory contents.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "prod-api",
|
||||
"remotePath": "/var/www"
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"name": "app",
|
||||
"path": "/var/www/app",
|
||||
"type": "directory",
|
||||
"size": 4096,
|
||||
"modifiedUtc": "2026-05-24T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## sftp_get
|
||||
|
||||
Download a remote file.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "prod-api",
|
||||
"remotePath": "/var/log/app.log",
|
||||
"localPath": "./downloads/app.log"
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"bytesTransferred": 123456,
|
||||
"localPath": "./downloads/app.log"
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
* Enforce max download size
|
||||
* Prevent unsafe local path traversal
|
||||
* Fail on overwrite unless explicitly enabled
|
||||
|
||||
---
|
||||
|
||||
## sftp_put
|
||||
|
||||
Upload a local file.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "prod-api",
|
||||
"localPath": "./dist/app.tar.gz",
|
||||
"remotePath": "/tmp/app.tar.gz",
|
||||
"overwrite": false
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"bytesTransferred": 123456,
|
||||
"remotePath": "/tmp/app.tar.gz"
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
* Enforce max upload size
|
||||
* Fail if remote file exists and overwrite is false
|
||||
|
||||
---
|
||||
|
||||
# Session Management
|
||||
|
||||
Terminal sessions should be maintained in memory.
|
||||
|
||||
Example session state:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "term_abc123",
|
||||
"host": "prod-api",
|
||||
"createdUtc": "...",
|
||||
"lastActivityUtc": "...",
|
||||
"idleTimeoutSeconds": 900
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
* Cryptographically random session IDs
|
||||
* Idle timeout cleanup
|
||||
* Graceful cleanup on shutdown
|
||||
* Output buffering with maximum limits
|
||||
|
||||
---
|
||||
|
||||
# Security Requirements
|
||||
|
||||
The implementation must default to safe behavior.
|
||||
|
||||
Required safeguards:
|
||||
|
||||
* Command timeout enforcement
|
||||
* Upload size limits
|
||||
* Download size limits
|
||||
* Audit logging
|
||||
* No private key persistence
|
||||
* No arbitrary local command execution
|
||||
* No command-content or host blocking; access control is delegated to SSH users, SSH keys, and remote-side permissions
|
||||
|
||||
---
|
||||
|
||||
# Audit Logging
|
||||
|
||||
Every tool call should be logged.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestampUtc": "2026-05-24T12:00:00Z",
|
||||
"tool": "ssh_exec",
|
||||
"host": "prod-api",
|
||||
"command": "systemctl status nginx",
|
||||
"success": true,
|
||||
"durationMs": 421
|
||||
}
|
||||
```
|
||||
|
||||
Sensitive values must be redacted.
|
||||
|
||||
---
|
||||
|
||||
# Recommended Implementation Stack
|
||||
|
||||
Language:
|
||||
|
||||
* C#
|
||||
* .NET 10+
|
||||
|
||||
Recommended libraries:
|
||||
|
||||
* Official MCP SDK
|
||||
* SSH.NET
|
||||
* OpenSSH `ssh -G`
|
||||
|
||||
---
|
||||
|
||||
# Recommended Internal Interfaces
|
||||
|
||||
```csharp
|
||||
public interface ISshConfigResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves an SSH host alias into effective connection settings.
|
||||
/// </summary>
|
||||
/// <param name="hostAlias">The SSH host alias.</param>
|
||||
/// <returns>The resolved SSH configuration.</returns>
|
||||
ResolvedSshConfig Resolve(string hostAlias);
|
||||
}
|
||||
|
||||
public interface ISshSessionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a connected SSH client.
|
||||
/// </summary>
|
||||
/// <param name="hostAlias">The SSH host alias.</param>
|
||||
/// <returns>A connected SSH client.</returns>
|
||||
SshClient CreateSshClient(string hostAlias);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a connected SFTP client.
|
||||
/// </summary>
|
||||
/// <param name="hostAlias">The SSH host alias.</param>
|
||||
/// <returns>A connected SFTP client.</returns>
|
||||
SftpClient CreateSftpClient(string hostAlias);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# MVP Scope
|
||||
|
||||
The MVP must include:
|
||||
|
||||
* Self-contained binary
|
||||
* stdio transport
|
||||
* ssh_exec
|
||||
* terminal_start
|
||||
* terminal_write
|
||||
* terminal_read
|
||||
* terminal_stop
|
||||
* basic audit logging
|
||||
|
||||
---
|
||||
|
||||
# Future Enhancements
|
||||
|
||||
Potential future work:
|
||||
|
||||
* Streamable HTTP transport
|
||||
* SCP compatibility fallback
|
||||
* Per-host policy configuration
|
||||
* File checksum validation
|
||||
* Directory upload/download
|
||||
* Remote command templates
|
||||
* Resource-based remote file browsing
|
||||
* Multi-hop SSH validation
|
||||
* Secret redaction improvements
|
||||
* Per-tool and per-host authorization policy
|
||||
8
McpSsh.slnx
Normal file
8
McpSsh.slnx
Normal file
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/McpSsh.Server/McpSsh.Server.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/McpSsh.Tests/McpSsh.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
34
README.md
Normal file
34
README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# MCP SSH Server
|
||||
|
||||
Self-contained MCP server for SSH command execution and persistent terminal sessions.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
dotnet build McpSsh.slnx
|
||||
dotnet test McpSsh.slnx --no-build
|
||||
```
|
||||
|
||||
## Publish
|
||||
|
||||
Publish all supported runtime IDs as single-file, self-contained binaries:
|
||||
|
||||
```bash
|
||||
./scripts/publish.sh
|
||||
```
|
||||
|
||||
Publish one runtime:
|
||||
|
||||
```bash
|
||||
./scripts/publish.sh linux-x64
|
||||
```
|
||||
|
||||
The script writes binaries under `artifacts/publish/<rid>/`.
|
||||
|
||||
Trimming is disabled by default because the MCP SDK discovers tools through reflection. To experiment with trimming after validating tool discovery in the published binary:
|
||||
|
||||
```bash
|
||||
PUBLISH_TRIMMED=true ./scripts/publish.sh linux-x64
|
||||
```
|
||||
|
||||
NativeAOT is not enabled. This code should be treated as not AOT-ready until the MCP SDK reflection path and SSH.NET dependencies are explicitly validated under `PublishAot=true`.
|
||||
25
scripts/publish.sh
Executable file
25
scripts/publish.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT="$ROOT_DIR/src/McpSsh.Server/McpSsh.Server.csproj"
|
||||
CONFIGURATION="${CONFIGURATION:-Release}"
|
||||
PUBLISH_TRIMMED="${PUBLISH_TRIMMED:-false}"
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
RIDS=("$@")
|
||||
else
|
||||
RIDS=(win-x64 linux-x64 linux-arm64 osx-x64 osx-arm64)
|
||||
fi
|
||||
|
||||
for rid in "${RIDS[@]}"; do
|
||||
output="$ROOT_DIR/artifacts/publish/$rid"
|
||||
dotnet publish "$PROJECT" \
|
||||
-c "$CONFIGURATION" \
|
||||
-r "$rid" \
|
||||
--self-contained true \
|
||||
-o "$output" \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:PublishTrimmed="$PUBLISH_TRIMMED" \
|
||||
-p:EnableCompressionInSingleFile=true
|
||||
done
|
||||
12
src/McpSsh.Server/Audit/AuditEvent.cs
Normal file
12
src/McpSsh.Server/Audit/AuditEvent.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace McpSsh.Server.Audit;
|
||||
|
||||
public sealed record AuditEvent(
|
||||
DateTimeOffset TimestampUtc,
|
||||
string Tool,
|
||||
string? Host,
|
||||
string? Username,
|
||||
string? Command,
|
||||
bool Success,
|
||||
long DurationMs,
|
||||
string? ErrorCode = null,
|
||||
string? Message = null);
|
||||
6
src/McpSsh.Server/Audit/IAuditLogger.cs
Normal file
6
src/McpSsh.Server/Audit/IAuditLogger.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace McpSsh.Server.Audit;
|
||||
|
||||
public interface IAuditLogger
|
||||
{
|
||||
void Log(AuditEvent auditEvent);
|
||||
}
|
||||
61
src/McpSsh.Server/Audit/JsonLineAuditLogger.cs
Normal file
61
src/McpSsh.Server/Audit/JsonLineAuditLogger.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace McpSsh.Server.Audit;
|
||||
|
||||
public sealed class JsonLineAuditLogger : IAuditLogger
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly TextWriter _writer;
|
||||
|
||||
public JsonLineAuditLogger()
|
||||
: this(Console.Error)
|
||||
{
|
||||
}
|
||||
|
||||
public JsonLineAuditLogger(TextWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public void Log(AuditEvent auditEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(auditEvent);
|
||||
|
||||
var safeEvent = auditEvent with
|
||||
{
|
||||
Command = Redact(auditEvent.Command),
|
||||
Message = Redact(auditEvent.Message)
|
||||
};
|
||||
|
||||
_writer.WriteLine(JsonSerializer.Serialize(safeEvent, SerializerOptions));
|
||||
}
|
||||
|
||||
private static string? Redact(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var redacted = value;
|
||||
foreach (var marker in new[] { "password=", "passphrase=", "token=", "secret=" })
|
||||
{
|
||||
var index = redacted.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var valueStart = index + marker.Length;
|
||||
var valueEnd = redacted.IndexOfAny([' ', '\t', '\r', '\n', '"', '\''], valueStart);
|
||||
if (valueEnd < 0)
|
||||
{
|
||||
valueEnd = redacted.Length;
|
||||
}
|
||||
|
||||
redacted = string.Concat(redacted.AsSpan(0, valueStart), "***", redacted.AsSpan(valueEnd));
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
}
|
||||
18
src/McpSsh.Server/McpSsh.Server.csproj
Normal file
18
src/McpSsh.Server/McpSsh.Server.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;linux-arm64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<AssemblyName>mcp-ssh</AssemblyName>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.3.0" />
|
||||
<PackageReference Include="SSH.NET" Version="2025.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
31
src/McpSsh.Server/Program.cs
Normal file
31
src/McpSsh.Server/Program.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using McpSsh.Server;
|
||||
using McpSsh.Server.Audit;
|
||||
using McpSsh.Server.Ssh;
|
||||
using McpSsh.Server.Terminal;
|
||||
using McpSsh.Server.Tools;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Logging.AddConsole(options =>
|
||||
{
|
||||
options.LogToStandardErrorThreshold = LogLevel.Trace;
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton<IFileSystem, LocalFileSystem>();
|
||||
builder.Services.AddSingleton<ISshKeyResolver, DefaultSshKeyResolver>();
|
||||
builder.Services.AddSingleton<IAuditLogger, JsonLineAuditLogger>();
|
||||
builder.Services.AddSingleton<ISshCommandExecutor, SshNetCommandExecutor>();
|
||||
builder.Services.AddSingleton<SshExecService>();
|
||||
builder.Services.AddSingleton<ITerminalConnectionFactory, SshNetTerminalConnectionFactory>();
|
||||
builder.Services.AddSingleton<TerminalSessionManager>();
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithTools<SshTools>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
91
src/McpSsh.Server/Ssh/DefaultSshKeyResolver.cs
Normal file
91
src/McpSsh.Server/Ssh/DefaultSshKeyResolver.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
namespace McpSsh.Server.Ssh;
|
||||
|
||||
public interface ISshKeyResolver
|
||||
{
|
||||
string ResolveKeyPath(string? requestedKeyPath);
|
||||
}
|
||||
|
||||
public interface IFileSystem
|
||||
{
|
||||
bool FileExists(string path);
|
||||
}
|
||||
|
||||
public sealed class LocalFileSystem : IFileSystem
|
||||
{
|
||||
public bool FileExists(string path) => File.Exists(path);
|
||||
}
|
||||
|
||||
public sealed class DefaultSshKeyResolver : ISshKeyResolver
|
||||
{
|
||||
private static readonly string[] DefaultKeyNames = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly string _sshDirectory;
|
||||
|
||||
public DefaultSshKeyResolver(IFileSystem fileSystem)
|
||||
: this(fileSystem, Path.Combine(GetHomeDirectory(), ".ssh"))
|
||||
{
|
||||
}
|
||||
|
||||
public DefaultSshKeyResolver(IFileSystem fileSystem, string sshDirectory)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_sshDirectory = sshDirectory;
|
||||
}
|
||||
|
||||
public string ResolveKeyPath(string? requestedKeyPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requestedKeyPath))
|
||||
{
|
||||
var expanded = ExpandHomeDirectory(requestedKeyPath.Trim());
|
||||
if (_fileSystem.FileExists(expanded))
|
||||
{
|
||||
return expanded;
|
||||
}
|
||||
|
||||
throw new SshToolException("ssh_key_not_found", $"SSH private key not found at '{expanded}'.");
|
||||
}
|
||||
|
||||
return ResolveDefaultKeyPath();
|
||||
}
|
||||
|
||||
private string ResolveDefaultKeyPath()
|
||||
{
|
||||
foreach (var keyName in DefaultKeyNames)
|
||||
{
|
||||
var path = Path.Combine(_sshDirectory, keyName);
|
||||
if (_fileSystem.FileExists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
throw new SshToolException("ssh_key_not_found", $"No default SSH private key found in '{_sshDirectory}'.");
|
||||
}
|
||||
|
||||
private static string ExpandHomeDirectory(string path)
|
||||
{
|
||||
if (path == "~")
|
||||
{
|
||||
return GetHomeDirectory();
|
||||
}
|
||||
|
||||
if (path.StartsWith("~/", StringComparison.Ordinal))
|
||||
{
|
||||
return Path.Combine(GetHomeDirectory(), path[2..]);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string GetHomeDirectory()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrWhiteSpace(home))
|
||||
{
|
||||
return home;
|
||||
}
|
||||
|
||||
return Environment.GetEnvironmentVariable("HOME")
|
||||
?? throw new SshToolException("home_directory_not_found", "Unable to determine the current user's home directory.");
|
||||
}
|
||||
}
|
||||
36
src/McpSsh.Server/Ssh/RemoteShellCommand.cs
Normal file
36
src/McpSsh.Server/Ssh/RemoteShellCommand.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Text;
|
||||
|
||||
namespace McpSsh.Server.Ssh;
|
||||
|
||||
public static class RemoteShellCommand
|
||||
{
|
||||
public static string Build(string command, string? cwd)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cwd))
|
||||
{
|
||||
return command;
|
||||
}
|
||||
|
||||
return $"cd {Quote(cwd)} && {command}";
|
||||
}
|
||||
|
||||
public static string Quote(string value)
|
||||
{
|
||||
var builder = new StringBuilder(value.Length + 2);
|
||||
builder.Append('\'');
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (character == '\'')
|
||||
{
|
||||
builder.Append("'\\''");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(character);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('\'');
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
73
src/McpSsh.Server/Ssh/SshCommandExecutor.cs
Normal file
73
src/McpSsh.Server/Ssh/SshCommandExecutor.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Renci.SshNet;
|
||||
using Renci.SshNet.Common;
|
||||
|
||||
namespace McpSsh.Server.Ssh;
|
||||
|
||||
public interface ISshCommandExecutor
|
||||
{
|
||||
Task<SshExecResult> ExecuteAsync(SshExecRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class SshNetCommandExecutor : ISshCommandExecutor
|
||||
{
|
||||
private readonly ISshKeyResolver _keyResolver;
|
||||
|
||||
public SshNetCommandExecutor(ISshKeyResolver keyResolver)
|
||||
{
|
||||
_keyResolver = keyResolver;
|
||||
}
|
||||
|
||||
public Task<SshExecResult> ExecuteAsync(SshExecRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(() => Execute(request, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private SshExecResult Execute(SshExecRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var keyPath = _keyResolver.ResolveKeyPath(request.KeyPath);
|
||||
using var keyFile = string.IsNullOrEmpty(request.KeyPassphrase)
|
||||
? new PrivateKeyFile(keyPath)
|
||||
: new PrivateKeyFile(keyPath, request.KeyPassphrase);
|
||||
using var client = new SshClient(request.Host, request.Port, request.Username, keyFile);
|
||||
|
||||
client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(request.TimeoutSeconds);
|
||||
client.Connect();
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var remoteCommand = RemoteShellCommand.Build(request.Command, request.Cwd);
|
||||
using var command = client.CreateCommand(remoteCommand);
|
||||
command.CommandTimeout = TimeSpan.FromSeconds(request.TimeoutSeconds);
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var stdout = command.Execute();
|
||||
var duration = DateTimeOffset.UtcNow - started;
|
||||
|
||||
return new SshExecResult(
|
||||
command.ExitStatus ?? -1,
|
||||
stdout,
|
||||
command.Error,
|
||||
(long)duration.TotalMilliseconds,
|
||||
TimedOut: false,
|
||||
Error: null,
|
||||
Message: null);
|
||||
}
|
||||
catch (SshAuthenticationException ex)
|
||||
{
|
||||
throw new SshToolException("ssh_authentication_failed", "SSH key authentication failed. Check the username, key path, and key passphrase.", ex);
|
||||
}
|
||||
catch (SshOperationTimeoutException ex)
|
||||
{
|
||||
throw new SshToolException("ssh_timeout", "SSH command timed out.", ex);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
throw new SshToolException("ssh_timeout", "SSH command timed out.", ex);
|
||||
}
|
||||
catch (SshException ex)
|
||||
{
|
||||
throw new SshToolException("ssh_error", ex.Message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/McpSsh.Server/Ssh/SshExecRequest.cs
Normal file
11
src/McpSsh.Server/Ssh/SshExecRequest.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace McpSsh.Server.Ssh;
|
||||
|
||||
public sealed record SshExecRequest(
|
||||
string Host,
|
||||
string Username,
|
||||
string Command,
|
||||
int Port,
|
||||
string? Cwd,
|
||||
int TimeoutSeconds,
|
||||
string? KeyPath,
|
||||
string? KeyPassphrase);
|
||||
10
src/McpSsh.Server/Ssh/SshExecResult.cs
Normal file
10
src/McpSsh.Server/Ssh/SshExecResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace McpSsh.Server.Ssh;
|
||||
|
||||
public sealed record SshExecResult(
|
||||
int ExitCode,
|
||||
string Stdout,
|
||||
string Stderr,
|
||||
long DurationMs,
|
||||
bool TimedOut,
|
||||
string? Error,
|
||||
string? Message);
|
||||
134
src/McpSsh.Server/Ssh/SshExecService.cs
Normal file
134
src/McpSsh.Server/Ssh/SshExecService.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.Diagnostics;
|
||||
using McpSsh.Server.Audit;
|
||||
|
||||
namespace McpSsh.Server.Ssh;
|
||||
|
||||
public sealed class SshExecService
|
||||
{
|
||||
public const int DefaultPort = 22;
|
||||
public const int DefaultTimeoutSeconds = 30;
|
||||
public const int MaxTimeoutSeconds = 300;
|
||||
|
||||
private readonly ISshCommandExecutor _executor;
|
||||
private readonly IAuditLogger _auditLogger;
|
||||
private readonly ISystemClock _clock;
|
||||
|
||||
public SshExecService(
|
||||
ISshCommandExecutor executor,
|
||||
IAuditLogger auditLogger,
|
||||
ISystemClock clock)
|
||||
{
|
||||
_executor = executor;
|
||||
_auditLogger = auditLogger;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public async Task<SshExecResult> ExecuteAsync(
|
||||
string host,
|
||||
string username,
|
||||
string command,
|
||||
int? port,
|
||||
string? cwd,
|
||||
string? keyPath,
|
||||
string? keyPassphrase,
|
||||
int? timeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = Validate(host, username, command, port, cwd, keyPath, keyPassphrase, timeoutSeconds);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(request.TimeoutSeconds));
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
|
||||
var result = await _executor.ExecuteAsync(request, linked.Token).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
Log(request, success: true, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return result with { DurationMs = result.DurationMs > 0 ? result.DurationMs : stopwatch.ElapsedMilliseconds };
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Log(request, success: false, stopwatch.ElapsedMilliseconds, "ssh_timeout", "SSH command timed out.");
|
||||
|
||||
return new SshExecResult(
|
||||
ExitCode: -1,
|
||||
Stdout: string.Empty,
|
||||
Stderr: string.Empty,
|
||||
DurationMs: stopwatch.ElapsedMilliseconds,
|
||||
TimedOut: true,
|
||||
Error: "ssh_timeout",
|
||||
Message: "SSH command timed out.");
|
||||
}
|
||||
catch (SshToolException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Log(request, success: false, stopwatch.ElapsedMilliseconds, ex.ErrorCode, ex.Message);
|
||||
|
||||
return new SshExecResult(
|
||||
ExitCode: -1,
|
||||
Stdout: string.Empty,
|
||||
Stderr: string.Empty,
|
||||
DurationMs: stopwatch.ElapsedMilliseconds,
|
||||
TimedOut: ex.ErrorCode == "ssh_timeout",
|
||||
Error: ex.ErrorCode,
|
||||
Message: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static SshExecRequest Validate(string host, string username, string command, int? port, string? cwd, string? keyPath, string? keyPassphrase, int? timeoutSeconds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
throw new SshToolException("invalid_host", "Host is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
throw new SshToolException("invalid_username", "Username is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
throw new SshToolException("invalid_command", "Command is required.");
|
||||
}
|
||||
|
||||
var resolvedPort = port ?? DefaultPort;
|
||||
if (resolvedPort is < 1 or > 65535)
|
||||
{
|
||||
throw new SshToolException("invalid_port", "Port must be between 1 and 65535.");
|
||||
}
|
||||
|
||||
var resolvedTimeout = timeoutSeconds ?? DefaultTimeoutSeconds;
|
||||
if (resolvedTimeout is < 1 or > MaxTimeoutSeconds)
|
||||
{
|
||||
throw new SshToolException("invalid_timeout", $"Timeout must be between 1 and {MaxTimeoutSeconds} seconds.");
|
||||
}
|
||||
|
||||
return new SshExecRequest(
|
||||
host.Trim(),
|
||||
username.Trim(),
|
||||
command,
|
||||
resolvedPort,
|
||||
string.IsNullOrWhiteSpace(cwd) ? null : cwd,
|
||||
resolvedTimeout,
|
||||
string.IsNullOrWhiteSpace(keyPath) ? null : keyPath.Trim(),
|
||||
string.IsNullOrEmpty(keyPassphrase) ? null : keyPassphrase);
|
||||
}
|
||||
|
||||
private void Log(SshExecRequest request, bool success, long durationMs, string? errorCode = null, string? message = null)
|
||||
{
|
||||
_auditLogger.Log(new AuditEvent(
|
||||
_clock.UtcNow,
|
||||
"ssh_exec",
|
||||
request.Host,
|
||||
request.Username,
|
||||
request.Command,
|
||||
success,
|
||||
durationMs,
|
||||
errorCode,
|
||||
message));
|
||||
}
|
||||
}
|
||||
18
src/McpSsh.Server/Ssh/SshToolException.cs
Normal file
18
src/McpSsh.Server/Ssh/SshToolException.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace McpSsh.Server.Ssh;
|
||||
|
||||
public sealed class SshToolException : Exception
|
||||
{
|
||||
public SshToolException(string errorCode, string message)
|
||||
: base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public SshToolException(string errorCode, string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public string ErrorCode { get; }
|
||||
}
|
||||
11
src/McpSsh.Server/SystemClock.cs
Normal file
11
src/McpSsh.Server/SystemClock.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace McpSsh.Server;
|
||||
|
||||
public interface ISystemClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
public sealed class SystemClock : ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
95
src/McpSsh.Server/Terminal/TerminalConnection.cs
Normal file
95
src/McpSsh.Server/Terminal/TerminalConnection.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using Renci.SshNet;
|
||||
using Renci.SshNet.Common;
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Server.Terminal;
|
||||
|
||||
public interface ITerminalConnection : IDisposable
|
||||
{
|
||||
bool DataAvailable { get; }
|
||||
|
||||
string ReadAvailable();
|
||||
|
||||
void Write(string input);
|
||||
}
|
||||
|
||||
public interface ITerminalConnectionFactory
|
||||
{
|
||||
ITerminalConnection Create(TerminalStartRequest request);
|
||||
}
|
||||
|
||||
public sealed class SshNetTerminalConnectionFactory : ITerminalConnectionFactory
|
||||
{
|
||||
private const int ShellBufferSize = 16 * 1024;
|
||||
private readonly ISshKeyResolver _keyResolver;
|
||||
|
||||
public SshNetTerminalConnectionFactory(ISshKeyResolver keyResolver)
|
||||
{
|
||||
_keyResolver = keyResolver;
|
||||
}
|
||||
|
||||
public ITerminalConnection Create(TerminalStartRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var keyPath = _keyResolver.ResolveKeyPath(request.KeyPath);
|
||||
var keyFile = string.IsNullOrEmpty(request.KeyPassphrase)
|
||||
? new PrivateKeyFile(keyPath)
|
||||
: new PrivateKeyFile(keyPath, request.KeyPassphrase);
|
||||
var client = new SshClient(request.Host, request.Port, request.Username, keyFile);
|
||||
|
||||
client.Connect();
|
||||
|
||||
var stream = client.CreateShellStream(
|
||||
"xterm-256color",
|
||||
(uint)request.Cols,
|
||||
(uint)request.Rows,
|
||||
width: 0,
|
||||
height: 0,
|
||||
bufferSize: ShellBufferSize);
|
||||
|
||||
return new SshNetTerminalConnection(client, stream, keyFile);
|
||||
}
|
||||
catch (SshAuthenticationException ex)
|
||||
{
|
||||
throw new SshToolException("ssh_authentication_failed", "SSH key authentication failed. Check the username, key path, and key passphrase.", ex);
|
||||
}
|
||||
catch (SshException ex)
|
||||
{
|
||||
throw new SshToolException("ssh_error", ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SshNetTerminalConnection : ITerminalConnection
|
||||
{
|
||||
private readonly SshClient _client;
|
||||
private readonly ShellStream _stream;
|
||||
private readonly PrivateKeyFile _keyFile;
|
||||
|
||||
public SshNetTerminalConnection(SshClient client, ShellStream stream, PrivateKeyFile keyFile)
|
||||
{
|
||||
_client = client;
|
||||
_stream = stream;
|
||||
_keyFile = keyFile;
|
||||
}
|
||||
|
||||
public bool DataAvailable => _stream.DataAvailable;
|
||||
|
||||
public string ReadAvailable()
|
||||
{
|
||||
return _stream.Read();
|
||||
}
|
||||
|
||||
public void Write(string input)
|
||||
{
|
||||
_stream.Write(input);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stream.Dispose();
|
||||
_client.Dispose();
|
||||
_keyFile.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/McpSsh.Server/Terminal/TerminalResults.cs
Normal file
33
src/McpSsh.Server/Terminal/TerminalResults.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace McpSsh.Server.Terminal;
|
||||
|
||||
public sealed record TerminalStartRequest(
|
||||
string Host,
|
||||
string Username,
|
||||
string? Shell,
|
||||
int Cols,
|
||||
int Rows,
|
||||
int Port,
|
||||
int IdleTimeoutSeconds,
|
||||
string? KeyPath,
|
||||
string? KeyPassphrase);
|
||||
|
||||
public sealed record TerminalStartResult(
|
||||
string? SessionId,
|
||||
string? Error = null,
|
||||
string? Message = null);
|
||||
|
||||
public sealed record TerminalWriteResult(
|
||||
bool Accepted,
|
||||
string? Error = null,
|
||||
string? Message = null);
|
||||
|
||||
public sealed record TerminalReadResult(
|
||||
string Output,
|
||||
bool Truncated,
|
||||
string? Error = null,
|
||||
string? Message = null);
|
||||
|
||||
public sealed record TerminalStopResult(
|
||||
bool Stopped,
|
||||
string? Error = null,
|
||||
string? Message = null);
|
||||
347
src/McpSsh.Server/Terminal/TerminalSessionManager.cs
Normal file
347
src/McpSsh.Server/Terminal/TerminalSessionManager.cs
Normal file
@@ -0,0 +1,347 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using McpSsh.Server.Audit;
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Server.Terminal;
|
||||
|
||||
public sealed class TerminalSessionManager : IDisposable
|
||||
{
|
||||
public const int DefaultPort = 22;
|
||||
public const int DefaultCols = 120;
|
||||
public const int DefaultRows = 40;
|
||||
public const int DefaultIdleTimeoutSeconds = 900;
|
||||
public const int MaxIdleTimeoutSeconds = 86_400;
|
||||
public const int DefaultReadMaxBytes = 12_000;
|
||||
public const int MaxReadBytes = 1_000_000;
|
||||
|
||||
private const int MaxBufferCharacters = 1_000_000;
|
||||
private readonly ConcurrentDictionary<string, TerminalSession> _sessions = new(StringComparer.Ordinal);
|
||||
private readonly ITerminalConnectionFactory _connectionFactory;
|
||||
private readonly IAuditLogger _auditLogger;
|
||||
private readonly ISystemClock _clock;
|
||||
private readonly Timer _cleanupTimer;
|
||||
|
||||
public TerminalSessionManager(ITerminalConnectionFactory connectionFactory, IAuditLogger auditLogger, ISystemClock clock)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_auditLogger = auditLogger;
|
||||
_clock = clock;
|
||||
_cleanupTimer = new Timer(_ => CleanupIdleSessions(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
public Task<TerminalStartResult> StartAsync(
|
||||
string host,
|
||||
string username,
|
||||
string? shell,
|
||||
int? cols,
|
||||
int? rows,
|
||||
int? port,
|
||||
string? keyPath,
|
||||
string? keyPassphrase,
|
||||
int? idleTimeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = ValidateStart(host, username, shell, cols, rows, port, keyPath, keyPassphrase, idleTimeoutSeconds);
|
||||
var started = _clock.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var connection = _connectionFactory.Create(request);
|
||||
var sessionId = CreateSessionId();
|
||||
var session = new TerminalSession(sessionId, request.Host, started, request.IdleTimeoutSeconds, connection);
|
||||
if (!_sessions.TryAdd(sessionId, session))
|
||||
{
|
||||
connection.Dispose();
|
||||
throw new SshToolException("terminal_session_conflict", "Unable to allocate a unique terminal session ID.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Shell))
|
||||
{
|
||||
connection.Write($"exec {request.Shell}\n");
|
||||
}
|
||||
|
||||
session.ReaderTask = Task.Run(() => ReadLoopAsync(session), CancellationToken.None);
|
||||
Log("terminal_start", request.Host, success: true, command: request.Shell);
|
||||
|
||||
return Task.FromResult(new TerminalStartResult(sessionId));
|
||||
}
|
||||
catch (SshToolException ex)
|
||||
{
|
||||
Log("terminal_start", request.Host, success: false, errorCode: ex.ErrorCode, message: ex.Message, command: request.Shell);
|
||||
return Task.FromResult(new TerminalStartResult(null, ex.ErrorCode, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public TerminalWriteResult Write(string sessionId, string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return new TerminalWriteResult(false, "invalid_session_id", "Session ID is required.");
|
||||
}
|
||||
|
||||
if (input is null)
|
||||
{
|
||||
return new TerminalWriteResult(false, "invalid_input", "Input is required.");
|
||||
}
|
||||
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return new TerminalWriteResult(false, "terminal_session_not_found", $"Terminal session '{sessionId}' was not found.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
session.Touch(_clock.UtcNow);
|
||||
session.Connection.Write(input);
|
||||
Log("terminal_write", session.Host, success: true, command: input);
|
||||
return new TerminalWriteResult(true);
|
||||
}
|
||||
catch (ObjectDisposedException ex)
|
||||
{
|
||||
RemoveSession(sessionId);
|
||||
Log("terminal_write", session.Host, success: false, errorCode: "terminal_session_closed", message: ex.Message, command: input);
|
||||
return new TerminalWriteResult(false, "terminal_session_closed", "Terminal session is closed.");
|
||||
}
|
||||
}
|
||||
|
||||
public TerminalReadResult Read(string sessionId, int? maxBytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return new TerminalReadResult(string.Empty, false, "invalid_session_id", "Session ID is required.");
|
||||
}
|
||||
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return new TerminalReadResult(string.Empty, false, "terminal_session_not_found", $"Terminal session '{sessionId}' was not found.");
|
||||
}
|
||||
|
||||
var byteLimit = ValidateMaxBytes(maxBytes);
|
||||
session.Touch(_clock.UtcNow);
|
||||
var (output, truncated) = session.Drain(byteLimit);
|
||||
Log("terminal_read", session.Host, success: true);
|
||||
return new TerminalReadResult(output, truncated);
|
||||
}
|
||||
|
||||
public TerminalStopResult Stop(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return new TerminalStopResult(false, "invalid_session_id", "Session ID is required.");
|
||||
}
|
||||
|
||||
if (!_sessions.TryRemove(sessionId, out var session))
|
||||
{
|
||||
return new TerminalStopResult(false, "terminal_session_not_found", $"Terminal session '{sessionId}' was not found.");
|
||||
}
|
||||
|
||||
session.Dispose();
|
||||
Log("terminal_stop", session.Host, success: true);
|
||||
return new TerminalStopResult(true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanupTimer.Dispose();
|
||||
foreach (var sessionId in _sessions.Keys)
|
||||
{
|
||||
RemoveSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReadLoopAsync(TerminalSession session)
|
||||
{
|
||||
while (!session.Cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (session.Connection.DataAvailable)
|
||||
{
|
||||
var output = session.Connection.ReadAvailable();
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
session.Append(output);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(50, session.Cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TerminalStartRequest ValidateStart(string host, string username, string? shell, int? cols, int? rows, int? port, string? keyPath, string? keyPassphrase, int? idleTimeoutSeconds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
throw new SshToolException("invalid_host", "Host is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
throw new SshToolException("invalid_username", "Username is required.");
|
||||
}
|
||||
|
||||
var resolvedPort = port ?? DefaultPort;
|
||||
if (resolvedPort is < 1 or > 65535)
|
||||
{
|
||||
throw new SshToolException("invalid_port", "Port must be between 1 and 65535.");
|
||||
}
|
||||
|
||||
var resolvedCols = cols ?? DefaultCols;
|
||||
if (resolvedCols is < 20 or > 500)
|
||||
{
|
||||
throw new SshToolException("invalid_cols", "Terminal columns must be between 20 and 500.");
|
||||
}
|
||||
|
||||
var resolvedRows = rows ?? DefaultRows;
|
||||
if (resolvedRows is < 5 or > 500)
|
||||
{
|
||||
throw new SshToolException("invalid_rows", "Terminal rows must be between 5 and 500.");
|
||||
}
|
||||
|
||||
var resolvedIdleTimeout = idleTimeoutSeconds ?? DefaultIdleTimeoutSeconds;
|
||||
if (resolvedIdleTimeout is < 1 or > MaxIdleTimeoutSeconds)
|
||||
{
|
||||
throw new SshToolException("invalid_idle_timeout", $"Idle timeout must be between 1 and {MaxIdleTimeoutSeconds} seconds.");
|
||||
}
|
||||
|
||||
return new TerminalStartRequest(
|
||||
host.Trim(),
|
||||
username.Trim(),
|
||||
string.IsNullOrWhiteSpace(shell) ? null : shell.Trim(),
|
||||
resolvedCols,
|
||||
resolvedRows,
|
||||
resolvedPort,
|
||||
resolvedIdleTimeout,
|
||||
string.IsNullOrWhiteSpace(keyPath) ? null : keyPath.Trim(),
|
||||
string.IsNullOrEmpty(keyPassphrase) ? null : keyPassphrase);
|
||||
}
|
||||
|
||||
private static int ValidateMaxBytes(int? maxBytes)
|
||||
{
|
||||
var resolved = maxBytes ?? DefaultReadMaxBytes;
|
||||
if (resolved is < 1 or > MaxReadBytes)
|
||||
{
|
||||
throw new SshToolException("invalid_max_bytes", $"maxBytes must be between 1 and {MaxReadBytes}.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private void CleanupIdleSessions()
|
||||
{
|
||||
var now = _clock.UtcNow;
|
||||
foreach (var (sessionId, session) in _sessions)
|
||||
{
|
||||
if (now - session.LastActivityUtc >= TimeSpan.FromSeconds(session.IdleTimeoutSeconds))
|
||||
{
|
||||
RemoveSession(sessionId);
|
||||
Log("terminal_idle_cleanup", session.Host, success: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveSession(string sessionId)
|
||||
{
|
||||
if (_sessions.TryRemove(sessionId, out var session))
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string tool, string? host, bool success, string? errorCode = null, string? message = null, string? command = null)
|
||||
{
|
||||
_auditLogger.Log(new AuditEvent(_clock.UtcNow, tool, host, null, command, success, 0, errorCode, message));
|
||||
}
|
||||
|
||||
private static string CreateSessionId()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[12];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return $"term_{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed class TerminalSession : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly StringBuilder _buffer = new();
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
|
||||
public TerminalSession(string sessionId, string host, DateTimeOffset now, int idleTimeoutSeconds, ITerminalConnection connection)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
Host = host;
|
||||
CreatedUtc = now;
|
||||
LastActivityUtc = now;
|
||||
IdleTimeoutSeconds = idleTimeoutSeconds;
|
||||
Connection = connection;
|
||||
Cancellation = _cancellation.Token;
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
public string Host { get; }
|
||||
public DateTimeOffset CreatedUtc { get; }
|
||||
public DateTimeOffset LastActivityUtc { get; private set; }
|
||||
public int IdleTimeoutSeconds { get; }
|
||||
public ITerminalConnection Connection { get; }
|
||||
public CancellationToken Cancellation { get; }
|
||||
public Task? ReaderTask { get; set; }
|
||||
|
||||
public void Touch(DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
LastActivityUtc = now;
|
||||
}
|
||||
}
|
||||
|
||||
public void Append(string output)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_buffer.Append(output);
|
||||
if (_buffer.Length > MaxBufferCharacters)
|
||||
{
|
||||
_buffer.Remove(0, _buffer.Length - MaxBufferCharacters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public (string Output, bool Truncated) Drain(int maxCharacters)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_buffer.Length == 0)
|
||||
{
|
||||
return (string.Empty, false);
|
||||
}
|
||||
|
||||
var count = Math.Min(maxCharacters, _buffer.Length);
|
||||
var output = _buffer.ToString(0, count);
|
||||
_buffer.Remove(0, count);
|
||||
return (output, _buffer.Length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cancellation.Cancel();
|
||||
Connection.Dispose();
|
||||
_cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/McpSsh.Server/Tools/SshTools.cs
Normal file
78
src/McpSsh.Server/Tools/SshTools.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.ComponentModel;
|
||||
using McpSsh.Server.Ssh;
|
||||
using McpSsh.Server.Terminal;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace McpSsh.Server.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class SshTools
|
||||
{
|
||||
private readonly SshExecService _sshExecService;
|
||||
private readonly TerminalSessionManager _terminalSessionManager;
|
||||
|
||||
public SshTools(SshExecService sshExecService, TerminalSessionManager terminalSessionManager)
|
||||
{
|
||||
_sshExecService = sshExecService;
|
||||
_terminalSessionManager = terminalSessionManager;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "ssh_exec", Destructive = true)]
|
||||
[Description("Execute a single command over SSH using key-based authentication.")]
|
||||
public Task<SshExecResult> ExecuteAsync(
|
||||
[Description("Remote hostname or IP address. OpenSSH aliases are not supported in this vertical slice.")] string host,
|
||||
[Description("Remote SSH username.")] string username,
|
||||
[Description("Command to execute on the remote host.")] string command,
|
||||
[Description("Remote SSH port. Defaults to 22.")] int? port = null,
|
||||
[Description("Optional remote working directory.")] string? cwd = null,
|
||||
[Description("Optional local private key path. Defaults to ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa, then ~/.ssh/id_rsa.")] string? keyPath = null,
|
||||
[Description("Optional private key passphrase. Use only with trusted MCP clients.")] string? keyPassphrase = null,
|
||||
[Description("Timeout in seconds. Defaults to 30 and is capped at 300.")] int? timeoutSeconds = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _sshExecService.ExecuteAsync(host, username, command, port, cwd, keyPath, keyPassphrase, timeoutSeconds, cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "terminal_start", Destructive = true)]
|
||||
[Description("Start a persistent SSH PTY shell session using key-based authentication.")]
|
||||
public Task<TerminalStartResult> StartTerminalAsync(
|
||||
[Description("Remote hostname or IP address. OpenSSH aliases are not supported in this vertical slice.")] string host,
|
||||
[Description("Remote SSH username.")] string username,
|
||||
[Description("Optional shell to exec after PTY allocation, for example bash or sh.")] string? shell = null,
|
||||
[Description("Terminal columns. Defaults to 120.")] int? cols = null,
|
||||
[Description("Terminal rows. Defaults to 40.")] int? rows = null,
|
||||
[Description("Remote SSH port. Defaults to 22.")] int? port = null,
|
||||
[Description("Optional local private key path. Defaults to ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa, then ~/.ssh/id_rsa.")] string? keyPath = null,
|
||||
[Description("Optional private key passphrase. Use only with trusted MCP clients.")] string? keyPassphrase = null,
|
||||
[Description("Idle timeout in seconds. Defaults to 900.")] int? idleTimeoutSeconds = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _terminalSessionManager.StartAsync(host, username, shell, cols, rows, port, keyPath, keyPassphrase, idleTimeoutSeconds, cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "terminal_write", Destructive = true)]
|
||||
[Description("Write input to an active SSH terminal session.")]
|
||||
public TerminalWriteResult WriteTerminal(
|
||||
[Description("Terminal session ID returned by terminal_start.")] string sessionId,
|
||||
[Description("Input to write to the terminal. Include newline characters when submitting commands.")] string input)
|
||||
{
|
||||
return _terminalSessionManager.Write(sessionId, input);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "terminal_read", Destructive = false)]
|
||||
[Description("Read buffered output from an active SSH terminal session.")]
|
||||
public TerminalReadResult ReadTerminal(
|
||||
[Description("Terminal session ID returned by terminal_start.")] string sessionId,
|
||||
[Description("Maximum output characters to read. Defaults to 12000.")] int? maxBytes = null)
|
||||
{
|
||||
return _terminalSessionManager.Read(sessionId, maxBytes);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "terminal_stop", Destructive = true)]
|
||||
[Description("Stop and remove an SSH terminal session.")]
|
||||
public TerminalStopResult StopTerminal(
|
||||
[Description("Terminal session ID returned by terminal_start.")] string sessionId)
|
||||
{
|
||||
return _terminalSessionManager.Stop(sessionId);
|
||||
}
|
||||
}
|
||||
59
tests/McpSsh.Tests/DefaultSshKeyResolverTests.cs
Normal file
59
tests/McpSsh.Tests/DefaultSshKeyResolverTests.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class DefaultSshKeyResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveDefaultKeyPath_ReturnsFirstExistingDefaultKey()
|
||||
{
|
||||
var fileSystem = new FakeFileSystem("/home/test/.ssh/id_ecdsa", "/home/test/.ssh/id_rsa");
|
||||
var resolver = new DefaultSshKeyResolver(fileSystem, "/home/test/.ssh");
|
||||
|
||||
var path = resolver.ResolveKeyPath(null);
|
||||
|
||||
Assert.Equal("/home/test/.ssh/id_ecdsa", path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDefaultKeyPath_ThrowsWhenNoDefaultKeyExists()
|
||||
{
|
||||
var resolver = new DefaultSshKeyResolver(new FakeFileSystem(), "/home/test/.ssh");
|
||||
|
||||
var ex = Assert.Throws<SshToolException>(() => resolver.ResolveKeyPath(null));
|
||||
|
||||
Assert.Equal("ssh_key_not_found", ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeyPath_ReturnsExplicitKeyWhenItExists()
|
||||
{
|
||||
var resolver = new DefaultSshKeyResolver(new FakeFileSystem("/keys/deploy_ed25519"), "/home/test/.ssh");
|
||||
|
||||
var path = resolver.ResolveKeyPath("/keys/deploy_ed25519");
|
||||
|
||||
Assert.Equal("/keys/deploy_ed25519", path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeyPath_ThrowsWhenExplicitKeyDoesNotExist()
|
||||
{
|
||||
var resolver = new DefaultSshKeyResolver(new FakeFileSystem(), "/home/test/.ssh");
|
||||
|
||||
var ex = Assert.Throws<SshToolException>(() => resolver.ResolveKeyPath("/keys/missing"));
|
||||
|
||||
Assert.Equal("ssh_key_not_found", ex.ErrorCode);
|
||||
}
|
||||
|
||||
private sealed class FakeFileSystem : IFileSystem
|
||||
{
|
||||
private readonly HashSet<string> _paths;
|
||||
|
||||
public FakeFileSystem(params string[] paths)
|
||||
{
|
||||
_paths = paths.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public bool FileExists(string path) => _paths.Contains(path);
|
||||
}
|
||||
}
|
||||
31
tests/McpSsh.Tests/JsonLineAuditLoggerTests.cs
Normal file
31
tests/McpSsh.Tests/JsonLineAuditLoggerTests.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
using McpSsh.Server.Audit;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class JsonLineAuditLoggerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Log_WritesJsonLineAndRedactsSensitiveMarkers()
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
var logger = new JsonLineAuditLogger(writer);
|
||||
|
||||
logger.Log(new AuditEvent(
|
||||
DateTimeOffset.Parse("2026-05-24T12:00:00Z"),
|
||||
"ssh_exec",
|
||||
"prod-api",
|
||||
"deploy",
|
||||
"echo token=abc123",
|
||||
Success: true,
|
||||
DurationMs: 42));
|
||||
|
||||
using var document = JsonDocument.Parse(writer.ToString());
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal("ssh_exec", root.GetProperty("tool").GetString());
|
||||
Assert.Equal("prod-api", root.GetProperty("host").GetString());
|
||||
Assert.Equal("echo token=***", root.GetProperty("command").GetString());
|
||||
Assert.True(root.GetProperty("success").GetBoolean());
|
||||
}
|
||||
}
|
||||
25
tests/McpSsh.Tests/McpSsh.Tests.csproj
Normal file
25
tests/McpSsh.Tests/McpSsh.Tests.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\McpSsh.Server\McpSsh.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
20
tests/McpSsh.Tests/RemoteShellCommandTests.cs
Normal file
20
tests/McpSsh.Tests/RemoteShellCommandTests.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class RemoteShellCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ReturnsCommandWhenCwdIsMissing()
|
||||
{
|
||||
Assert.Equal("pwd", RemoteShellCommand.Build("pwd", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PrependsQuotedCwd()
|
||||
{
|
||||
var command = RemoteShellCommand.Build("ls", "/srv/app's/current");
|
||||
|
||||
Assert.Equal("cd '/srv/app'\\''s/current' && ls", command);
|
||||
}
|
||||
}
|
||||
110
tests/McpSsh.Tests/SshExecServiceTests.cs
Normal file
110
tests/McpSsh.Tests/SshExecServiceTests.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using McpSsh.Server;
|
||||
using McpSsh.Server.Audit;
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class SshExecServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PassesValidatedRequestToExecutor()
|
||||
{
|
||||
var executor = new CapturingExecutor(new SshExecResult(0, "ok", "", 12, false, null, null));
|
||||
var auditLogger = new CapturingAuditLogger();
|
||||
var service = CreateService(executor, auditLogger: auditLogger);
|
||||
|
||||
var result = await service.ExecuteAsync(" prod-api ", " deploy ", "uptime", null, null, " /keys/deploy ", "secret", null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.NotNull(executor.Request);
|
||||
Assert.Equal("prod-api", executor.Request.Host);
|
||||
Assert.Equal("deploy", executor.Request.Username);
|
||||
Assert.Equal(22, executor.Request.Port);
|
||||
Assert.Equal(30, executor.Request.TimeoutSeconds);
|
||||
Assert.Equal("/keys/deploy", executor.Request.KeyPath);
|
||||
Assert.Equal("secret", executor.Request.KeyPassphrase);
|
||||
Assert.Single(auditLogger.Events);
|
||||
Assert.True(auditLogger.Events[0].Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_AllowsDestructiveCommands()
|
||||
{
|
||||
var executor = new CapturingExecutor();
|
||||
var service = CreateService(executor);
|
||||
|
||||
var result = await service.ExecuteAsync("prod-api", "deploy", "rm -rf /", null, null, null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Equal("rm -rf /", executor.Request?.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsTimeoutWhenExecutorObservesCancellation()
|
||||
{
|
||||
var executor = new BlockingExecutor();
|
||||
var service = CreateService(executor);
|
||||
|
||||
var result = await service.ExecuteAsync("prod-api", "deploy", "sleep 5", null, null, null, null, 1, CancellationToken.None);
|
||||
|
||||
Assert.True(result.TimedOut);
|
||||
Assert.Equal("ssh_timeout", result.Error);
|
||||
}
|
||||
|
||||
private static SshExecService CreateService(
|
||||
ISshCommandExecutor executor,
|
||||
IAuditLogger? auditLogger = null)
|
||||
{
|
||||
return new SshExecService(
|
||||
executor,
|
||||
auditLogger ?? new CapturingAuditLogger(),
|
||||
new FixedClock());
|
||||
}
|
||||
|
||||
private sealed class CapturingExecutor : ISshCommandExecutor
|
||||
{
|
||||
private readonly SshExecResult _result;
|
||||
|
||||
public CapturingExecutor()
|
||||
: this(new SshExecResult(0, "", "", 1, false, null, null))
|
||||
{
|
||||
}
|
||||
|
||||
public CapturingExecutor(SshExecResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public SshExecRequest? Request { get; private set; }
|
||||
|
||||
public Task<SshExecResult> ExecuteAsync(SshExecRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Request = request;
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingExecutor : ISshCommandExecutor
|
||||
{
|
||||
public async Task<SshExecResult> ExecuteAsync(SshExecRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
|
||||
return new SshExecResult(0, "", "", 0, false, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingAuditLogger : IAuditLogger
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = [];
|
||||
|
||||
public void Log(AuditEvent auditEvent)
|
||||
{
|
||||
Events.Add(auditEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedClock : ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-05-24T12:00:00Z");
|
||||
}
|
||||
}
|
||||
158
tests/McpSsh.Tests/TerminalSessionManagerTests.cs
Normal file
158
tests/McpSsh.Tests/TerminalSessionManagerTests.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Collections.Concurrent;
|
||||
using McpSsh.Server;
|
||||
using McpSsh.Server.Audit;
|
||||
using McpSsh.Server.Terminal;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class TerminalSessionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartAsync_CreatesSessionAndExecsRequestedShell()
|
||||
{
|
||||
using var manager = CreateManager(out var factory, out _);
|
||||
|
||||
var result = await manager.StartAsync(" prod-api ", " deploy ", "bash", null, null, null, "/keys/id", "secret", null, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.StartsWith("term_", result.SessionId);
|
||||
Assert.NotNull(factory.Request);
|
||||
Assert.Equal("prod-api", factory.Request.Host);
|
||||
Assert.Equal("deploy", factory.Request.Username);
|
||||
Assert.Equal("/keys/id", factory.Request.KeyPath);
|
||||
Assert.Equal("secret", factory.Request.KeyPassphrase);
|
||||
Assert.Contains("exec bash\n", factory.Connection.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_SendsInputToActiveSession()
|
||||
{
|
||||
using var manager = CreateManager(out var factory, out _);
|
||||
var start = await manager.StartAsync("prod-api", "deploy", null, null, null, null, null, null, null, CancellationToken.None);
|
||||
|
||||
var result = manager.Write(start.SessionId!, "uptime\n");
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Contains("uptime\n", factory.Connection.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_DrainsBufferedOutputAndReportsTruncation()
|
||||
{
|
||||
using var manager = CreateManager(out var factory, out _);
|
||||
var start = await manager.StartAsync("prod-api", "deploy", null, null, null, null, null, null, null, CancellationToken.None);
|
||||
factory.Connection.QueueOutput("abcdef");
|
||||
|
||||
var first = await ReadUntilOutputAsync(manager, start.SessionId!, 3);
|
||||
var second = manager.Read(start.SessionId!, 10);
|
||||
|
||||
Assert.Equal("abc", first.Output);
|
||||
Assert.True(first.Truncated);
|
||||
Assert.Equal("def", second.Output);
|
||||
Assert.False(second.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_ReturnsNotFoundForMissingSession()
|
||||
{
|
||||
using var manager = CreateManager(out _, out _);
|
||||
|
||||
var result = manager.Write("term_missing", "pwd\n");
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.Equal("terminal_session_not_found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_DisposesAndRemovesSession()
|
||||
{
|
||||
using var manager = CreateManager(out var factory, out _);
|
||||
var start = await manager.StartAsync("prod-api", "deploy", null, null, null, null, null, null, null, CancellationToken.None);
|
||||
|
||||
var result = manager.Stop(start.SessionId!);
|
||||
var writeAfterStop = manager.Write(start.SessionId!, "pwd\n");
|
||||
|
||||
Assert.True(result.Stopped);
|
||||
Assert.True(factory.Connection.Disposed);
|
||||
Assert.Equal("terminal_session_not_found", writeAfterStop.Error);
|
||||
}
|
||||
|
||||
private static TerminalSessionManager CreateManager(out FakeTerminalConnectionFactory factory, out CapturingAuditLogger auditLogger)
|
||||
{
|
||||
factory = new FakeTerminalConnectionFactory();
|
||||
auditLogger = new CapturingAuditLogger();
|
||||
return new TerminalSessionManager(factory, auditLogger, new FixedClock());
|
||||
}
|
||||
|
||||
private static async Task<TerminalReadResult> ReadUntilOutputAsync(TerminalSessionManager manager, string sessionId, int maxBytes)
|
||||
{
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
var result = manager.Read(sessionId, maxBytes);
|
||||
if (result.Output.Length > 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
return manager.Read(sessionId, maxBytes);
|
||||
}
|
||||
|
||||
private sealed class FakeTerminalConnectionFactory : ITerminalConnectionFactory
|
||||
{
|
||||
public FakeTerminalConnection Connection { get; } = new();
|
||||
public TerminalStartRequest? Request { get; private set; }
|
||||
|
||||
public ITerminalConnection Create(TerminalStartRequest request)
|
||||
{
|
||||
Request = request;
|
||||
return Connection;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTerminalConnection : ITerminalConnection
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _output = new();
|
||||
|
||||
public List<string> Writes { get; } = [];
|
||||
public bool Disposed { get; private set; }
|
||||
public bool DataAvailable => !_output.IsEmpty;
|
||||
|
||||
public string ReadAvailable()
|
||||
{
|
||||
return _output.TryDequeue(out var output) ? output : string.Empty;
|
||||
}
|
||||
|
||||
public void Write(string input)
|
||||
{
|
||||
Writes.Add(input);
|
||||
}
|
||||
|
||||
public void QueueOutput(string output)
|
||||
{
|
||||
_output.Enqueue(output);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingAuditLogger : IAuditLogger
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = [];
|
||||
|
||||
public void Log(AuditEvent auditEvent)
|
||||
{
|
||||
Events.Add(auditEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedClock : ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-05-24T12:00:00Z");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user