Skip to content

Commit 17fdd2b

Browse files
committed
fixing mcp path
1 parent 8b43875 commit 17fdd2b

9 files changed

Lines changed: 663 additions & 126 deletions

File tree

docker-compose.mcp-debug.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
mcp-server:
3+
build:
4+
context: .
5+
dockerfile: src/DonkeyWork.CodeSandbox.McpServer/Dockerfile
6+
container_name: mcp-server-debug
7+
ports:
8+
- "8666:8666"
9+
environment:
10+
- ASPNETCORE_URLS=http://+:8666
11+
- ASPNETCORE_ENVIRONMENT=Development
12+
- Serilog__MinimumLevel__Default=Debug
13+
- Serilog__MinimumLevel__Override__Microsoft=Warning
14+
- Serilog__MinimumLevel__Override__System=Warning

frontend/src/components/mcp/McpServerDetail.tsx

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export function McpServerDetail({ serverId, creationInfo, onDelete }: McpServerD
3636
const [startError, setStartError] = useState<string | null>(null)
3737
const [isStopping, setIsStopping] = useState(false)
3838

39+
// Start events state
40+
const [startEvents, setStartEvents] = useState<Array<{ eventType: string; message: string; stream?: string; pid?: number; elapsedSeconds?: number }>>([])
41+
3942
// Proxy state
4043
const [proxyBody, setProxyBody] = useState('{\n "jsonrpc": "2.0",\n "method": "tools/list",\n "id": 1\n}')
4144
const [proxyExecutions, setProxyExecutions] = useState<ProxyExecution[]>([])
@@ -67,35 +70,36 @@ export function McpServerDetail({ serverId, creationInfo, onDelete }: McpServerD
6770
if (!command.trim()) return
6871
setIsStarting(true)
6972
setStartError(null)
70-
71-
try {
72-
// Parse arguments - split by whitespace but respect quoted strings
73-
const parseArgs = (str: string): string[] => {
74-
if (!str.trim()) return []
75-
const args: string[] = []
76-
let current = ''
77-
let inQuote = false
78-
let quoteChar = ''
79-
for (const char of str) {
80-
if ((char === '"' || char === "'") && !inQuote) {
81-
inQuote = true
82-
quoteChar = char
83-
} else if (char === quoteChar && inQuote) {
84-
inQuote = false
85-
quoteChar = ''
86-
} else if (char === ' ' && !inQuote) {
87-
if (current) {
88-
args.push(current)
89-
current = ''
90-
}
91-
} else {
92-
current += char
73+
setStartEvents([])
74+
75+
// Parse arguments - split by whitespace but respect quoted strings
76+
const parseArgs = (str: string): string[] => {
77+
if (!str.trim()) return []
78+
const args: string[] = []
79+
let current = ''
80+
let inQuote = false
81+
let quoteChar = ''
82+
for (const char of str) {
83+
if ((char === '"' || char === "'") && !inQuote) {
84+
inQuote = true
85+
quoteChar = char
86+
} else if (char === quoteChar && inQuote) {
87+
inQuote = false
88+
quoteChar = ''
89+
} else if (char === ' ' && !inQuote) {
90+
if (current) {
91+
args.push(current)
92+
current = ''
9393
}
94+
} else {
95+
current += char
9496
}
95-
if (current) args.push(current)
96-
return args
9797
}
98+
if (current) args.push(current)
99+
return args
100+
}
98101

102+
try {
99103
const response = await fetch(`/api/mcp-servers/${serverId}/start`, {
100104
method: 'POST',
101105
headers: { 'Content-Type': 'application/json' },
@@ -109,11 +113,39 @@ export function McpServerDetail({ serverId, creationInfo, onDelete }: McpServerD
109113
}),
110114
})
111115

112-
if (!response.ok) {
116+
if (!response.ok && !response.headers.get('content-type')?.includes('text/event-stream')) {
113117
const errorText = await response.text()
114118
throw new Error(errorText || `HTTP ${response.status}`)
115119
}
116120

121+
const reader = response.body?.getReader()
122+
if (!reader) throw new Error('No response body')
123+
124+
const decoder = new TextDecoder()
125+
let buffer = ''
126+
127+
while (true) {
128+
const { done, value } = await reader.read()
129+
if (done) break
130+
131+
buffer += decoder.decode(value, { stream: true })
132+
const lines = buffer.split('\n')
133+
buffer = lines.pop() || ''
134+
135+
for (const line of lines) {
136+
if (!line.startsWith('data: ')) continue
137+
try {
138+
const evt = JSON.parse(line.slice(6))
139+
setStartEvents(prev => [...prev, evt])
140+
if (evt.eventType === 'error') {
141+
setStartError(evt.message)
142+
}
143+
} catch {
144+
// skip malformed events
145+
}
146+
}
147+
}
148+
117149
await fetchStatus()
118150
} catch (error) {
119151
setStartError(error instanceof Error ? error.message : 'Failed to start MCP process')
@@ -385,6 +417,34 @@ export function McpServerDetail({ serverId, creationInfo, onDelete }: McpServerD
385417
<span>{startError}</span>
386418
</div>
387419
)}
420+
421+
{/* Start Events Log */}
422+
{startEvents.length > 0 && (
423+
<div className="border border-border rounded-lg bg-muted/20 p-3 max-h-[250px] overflow-auto">
424+
<div className="text-xs text-muted-foreground mb-2">Startup Events:</div>
425+
<ul className="space-y-1 font-mono text-xs">
426+
{startEvents.map((evt, idx) => (
427+
<li key={idx} className="flex items-start gap-2">
428+
<span className={cn(
429+
"shrink-0 px-1.5 py-0.5 rounded",
430+
evt.eventType === 'ready' && "bg-green-500/20 text-green-600 dark:text-green-400",
431+
evt.eventType === 'error' && "bg-red-500/20 text-red-600 dark:text-red-400",
432+
evt.eventType.startsWith('handshake') && "bg-blue-500/20 text-blue-600 dark:text-blue-400",
433+
evt.eventType.startsWith('process') && "bg-purple-500/20 text-purple-600 dark:text-purple-400",
434+
evt.eventType.startsWith('pre_exec') && "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400",
435+
evt.eventType === 'mcp_stderr' && "bg-orange-500/20 text-orange-600 dark:text-orange-400",
436+
)}>
437+
{evt.eventType}
438+
</span>
439+
<span className="break-all">{evt.message}</span>
440+
{evt.elapsedSeconds != null && (
441+
<span className="shrink-0 text-muted-foreground">{evt.elapsedSeconds.toFixed(1)}s</span>
442+
)}
443+
</li>
444+
))}
445+
</ul>
446+
</div>
447+
)}
388448
</div>
389449
</div>
390450
</TabsContent>

src/DonkeyWork.CodeSandbox.Manager/Endpoints/McpServerEndpoints.cs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public static void MapMcpServerEndpoints(this IEndpointRouteBuilder app)
4242
group.MapPost("/{podName}/start", StartMcpProcess)
4343
.WithName("StartMcpProcess")
4444
.WithSummary("Start (arm) the MCP process inside a container")
45-
.WithDescription("Sends the launch command and pre-exec scripts to start the MCP stdio process inside an already-running container.");
45+
.WithDescription("Sends the launch command and pre-exec scripts to start the MCP stdio process inside an already-running container. Returns SSE stream with startup events.");
4646

4747
group.MapPost("/{podName}/proxy", ProxyMcpRequest)
4848
.WithName("ProxyMcpRequest")
@@ -194,22 +194,50 @@ private static async Task<Results<Ok<KataContainerInfo>, StatusCodeHttpResult, B
194194
}
195195
}
196196

197-
private static async Task<Results<Ok, ProblemHttpResult>> StartMcpProcess(
197+
private static async Task StartMcpProcess(
198198
string podName,
199199
McpStartRequest request,
200200
IMcpContainerService mcpService,
201201
ILogger<Program> logger,
202+
HttpContext context,
202203
CancellationToken cancellationToken)
203204
{
205+
context.Response.Headers["Content-Type"] = "text/event-stream";
206+
context.Response.Headers["Cache-Control"] = "no-cache";
207+
context.Response.Headers["Connection"] = "keep-alive";
208+
204209
try
205210
{
206-
await mcpService.StartMcpProcessAsync(podName, request, cancellationToken);
207-
return TypedResults.Ok();
211+
await foreach (var evt in mcpService.StartMcpProcessAsync(podName, request, cancellationToken))
212+
{
213+
var json = System.Text.Json.JsonSerializer.Serialize(evt, new System.Text.Json.JsonSerializerOptions
214+
{
215+
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
216+
});
217+
218+
var sseMessage = $"data: {json}\n\n";
219+
await context.Response.Body.WriteAsync(System.Text.Encoding.UTF8.GetBytes(sseMessage), cancellationToken);
220+
await context.Response.Body.FlushAsync(cancellationToken);
221+
}
208222
}
209223
catch (Exception ex)
210224
{
211225
logger.LogError(ex, "Failed to start MCP process in {PodName}", podName);
212-
return TypedResults.Problem(detail: ex.Message, statusCode: 500, title: "Failed to start MCP process");
226+
227+
var errorEvent = new McpStartProcessEvent
228+
{
229+
EventType = "error",
230+
Message = ex.Message
231+
};
232+
233+
var json = System.Text.Json.JsonSerializer.Serialize(errorEvent, new System.Text.Json.JsonSerializerOptions
234+
{
235+
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
236+
});
237+
238+
var sseMessage = $"data: {json}\n\n";
239+
await context.Response.Body.WriteAsync(System.Text.Encoding.UTF8.GetBytes(sseMessage), cancellationToken);
240+
await context.Response.Body.FlushAsync(cancellationToken);
213241
}
214242
}
215243

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace DonkeyWork.CodeSandbox.Manager.Models;
2+
3+
/// <summary>
4+
/// Event streamed from the MCP server's /api/mcp/start SSE endpoint.
5+
/// Mirrors the McpStartEvent model from the MCP Server project.
6+
/// </summary>
7+
public class McpStartProcessEvent
8+
{
9+
public string EventType { get; set; } = string.Empty;
10+
public string Message { get; set; } = string.Empty;
11+
public string? Stream { get; set; }
12+
public int? ExitCode { get; set; }
13+
public int? Pid { get; set; }
14+
public double? ElapsedSeconds { get; set; }
15+
}

src/DonkeyWork.CodeSandbox.Manager/Services/Mcp/IMcpContainerService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ IAsyncEnumerable<ContainerCreationEvent> CreateMcpServerAsync(
1818

1919
/// <summary>
2020
/// Arms an MCP server container by calling POST /api/mcp/start with the given launch command/args.
21+
/// Streams SSE events from the MCP server during startup.
2122
/// </summary>
22-
Task StartMcpProcessAsync(string podName, McpStartRequest request, CancellationToken cancellationToken = default);
23+
IAsyncEnumerable<McpStartProcessEvent> StartMcpProcessAsync(string podName, McpStartRequest request, CancellationToken cancellationToken = default);
2324

2425
/// <summary>
2526
/// Proxies a raw JSON-RPC request body to the MCP server inside the container.

src/DonkeyWork.CodeSandbox.Manager/Services/Mcp/McpContainerService.cs

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,15 @@ private async Task CreateMcpServerInternalAsync(
117117
var podIp = currentPod.Status?.PodIP
118118
?? throw new InvalidOperationException("Pod has no IP");
119119

120-
await StartMcpProcessOnPodAsync(podIp, request, cancellationToken);
120+
// Consume SSE events from the MCP server start and forward to the creation stream
121+
await foreach (var startEvt in StartMcpProcessOnPodSseAsync(podIp, request, cancellationToken))
122+
{
123+
writer.TryWrite(new McpServerStartingEvent
124+
{
125+
PodName = podName,
126+
Message = $"[{startEvt.EventType}] {startEvt.Message}"
127+
});
128+
}
121129

122130
var totalElapsed = (DateTime.UtcNow - startTime).TotalSeconds;
123131
writer.TryWrite(new McpServerStartedEvent
@@ -352,12 +360,22 @@ await _client.CoreV1.DeleteNamespacedPodAsync(
352360
return null;
353361
}
354362

355-
public async Task StartMcpProcessAsync(string podName, McpStartRequest request, CancellationToken cancellationToken = default)
363+
public async IAsyncEnumerable<McpStartProcessEvent> StartMcpProcessAsync(
364+
string podName,
365+
McpStartRequest request,
366+
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
356367
{
357368
var commandDisplay = $"{request.Command} {string.Join(" ", request.Arguments)}";
358369
_logger.LogInformation("Starting MCP process in {PodName}: {Command}", podName, commandDisplay);
359370

360371
var podIp = await GetPodIpAsync(podName, cancellationToken);
372+
var podUrl = $"http://{podIp}:8666";
373+
374+
yield return new McpStartProcessEvent
375+
{
376+
EventType = "connecting",
377+
Message = $"Connecting to {podUrl}"
378+
};
361379

362380
// Store launch command in annotation (for display purposes)
363381
try
@@ -380,13 +398,16 @@ await _client.CoreV1.PatchNamespacedPodAsync(
380398
_logger.LogWarning(ex, "Failed to store launch command annotation for {PodName}", podName);
381399
}
382400

383-
await StartMcpProcessOnPodAsync(podIp, new CreateMcpServerRequest
401+
await foreach (var evt in StartMcpProcessOnPodSseAsync(podIp, new CreateMcpServerRequest
384402
{
385403
Command = request.Command,
386404
Arguments = request.Arguments,
387405
PreExecScripts = request.PreExecScripts,
388406
TimeoutSeconds = request.TimeoutSeconds
389-
}, cancellationToken);
407+
}, cancellationToken))
408+
{
409+
yield return evt;
410+
}
390411
}
391412

392413
public async Task<string> ProxyMcpRequestAsync(string podName, string jsonRpcBody, CancellationToken cancellationToken = default)
@@ -398,7 +419,7 @@ public async Task<string> ProxyMcpRequestAsync(string podName, string jsonRpcBod
398419

399420
var httpClient = _httpClientFactory.CreateClient();
400421
var response = await httpClient.PostAsync(
401-
$"http://{podIp}:8666/api/mcp/",
422+
$"http://{podIp}:8666/mcp",
402423
new StringContent(jsonRpcBody, Encoding.UTF8, "application/json"),
403424
cancellationToken);
404425

@@ -433,7 +454,7 @@ public async Task StopMcpProcessAsync(string podName, CancellationToken cancella
433454
var podIp = await GetPodIpAsync(podName, cancellationToken);
434455
var httpClient = _httpClientFactory.CreateClient();
435456
var response = await httpClient.DeleteAsync(
436-
$"http://{podIp}:8666/api/mcp/",
457+
$"http://{podIp}:8666/api/mcp",
437458
cancellationToken);
438459
response.EnsureSuccessStatusCode();
439460
}
@@ -574,7 +595,10 @@ private V1Pod BuildMcpPodSpec(string podName, CreateMcpServerRequest request)
574595
};
575596
}
576597

577-
private async Task StartMcpProcessOnPodAsync(string podIp, CreateMcpServerRequest request, CancellationToken cancellationToken)
598+
private async IAsyncEnumerable<McpStartProcessEvent> StartMcpProcessOnPodSseAsync(
599+
string podIp,
600+
CreateMcpServerRequest request,
601+
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
578602
{
579603
var httpClient = _httpClientFactory.CreateClient();
580604
var startPayload = new
@@ -586,16 +610,50 @@ private async Task StartMcpProcessOnPodAsync(string podIp, CreateMcpServerReques
586610
};
587611

588612
var json = JsonSerializer.Serialize(startPayload);
589-
var response = await httpClient.PostAsync(
590-
$"http://{podIp}:8666/api/mcp/start",
591-
new StringContent(json, Encoding.UTF8, "application/json"),
592-
cancellationToken);
613+
using var requestMessage = new HttpRequestMessage(HttpMethod.Post, $"http://{podIp}:8666/api/mcp/start")
614+
{
615+
Content = new StringContent(json, Encoding.UTF8, "application/json")
616+
};
617+
618+
using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
593619

594620
if (!response.IsSuccessStatusCode)
595621
{
596622
var error = await response.Content.ReadAsStringAsync(cancellationToken);
597623
throw new InvalidOperationException($"MCP start failed ({response.StatusCode}): {error}");
598624
}
625+
626+
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
627+
using var reader = new StreamReader(stream);
628+
629+
string? line;
630+
while ((line = await reader.ReadLineAsync(cancellationToken)) is not null)
631+
{
632+
633+
if (string.IsNullOrWhiteSpace(line))
634+
continue;
635+
636+
if (!line.StartsWith("data: "))
637+
continue;
638+
639+
var eventJson = line["data: ".Length..];
640+
McpStartProcessEvent? evt;
641+
try
642+
{
643+
evt = JsonSerializer.Deserialize<McpStartProcessEvent>(eventJson, new JsonSerializerOptions
644+
{
645+
PropertyNameCaseInsensitive = true
646+
});
647+
}
648+
catch (JsonException ex)
649+
{
650+
_logger.LogWarning(ex, "Failed to deserialize MCP start SSE event: {Json}", eventJson);
651+
continue;
652+
}
653+
654+
if (evt is not null)
655+
yield return evt;
656+
}
599657
}
600658

601659
private async Task<string> GetPodIpAsync(string podName, CancellationToken cancellationToken)

0 commit comments

Comments
 (0)