Skip to content

Commit 7a26c94

Browse files
authored
Add whiteboard sample and MCP server implementation (#312)
* Add whiteboard sample and MCP server implementation * Change to top level statements and remove Startup.cs
1 parent 4c95215 commit 7a26c94

File tree

17 files changed

+2536
-0
lines changed

17 files changed

+2536
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.AspNetCore.SignalR;
7+
using System;
8+
using System.IO;
9+
using System.Threading.Tasks;
10+
11+
namespace Microsoft.Azure.SignalR.Samples.Whiteboard;
12+
13+
[Route("/background")]
14+
public class BackgroundController(IHubContext<DrawHub> context, Diagram diagram) : Controller
15+
{
16+
private readonly IHubContext<DrawHub> hubContext = context;
17+
private readonly Diagram diagram = diagram;
18+
19+
[HttpPost("upload")]
20+
public async Task<IActionResult> Upload(IFormFile file)
21+
{
22+
diagram.BackgroundId = Guid.NewGuid().ToString().Substring(0, 8);
23+
diagram.Background = new byte[file.Length];
24+
diagram.BackgroundContentType = file.ContentType;
25+
using (var stream = new MemoryStream(diagram.Background))
26+
{
27+
await file.CopyToAsync(stream);
28+
}
29+
30+
await hubContext.Clients.All.SendAsync("BackgroundUpdated", diagram.BackgroundId);
31+
32+
return Ok();
33+
}
34+
35+
[HttpGet("{id}")]
36+
public IActionResult Download(string id)
37+
{
38+
if (diagram.BackgroundId != id) return NotFound();
39+
return File(diagram.Background, diagram.BackgroundContentType);
40+
}
41+
}

samples/Whiteboard/Diagram.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System.Collections.Concurrent;
5+
using System.Collections.Generic;
6+
using System.Threading;
7+
8+
namespace Microsoft.Azure.SignalR.Samples.Whiteboard;
9+
10+
public class Point
11+
{
12+
public int X { get; set; }
13+
public int Y { get; set; }
14+
}
15+
16+
public abstract class Shape
17+
{
18+
public string Color { get; set; }
19+
20+
public int Width { get; set; }
21+
}
22+
23+
public class Polyline : Shape
24+
{
25+
public List<Point> Points { get; set; }
26+
}
27+
28+
public class Line : Shape
29+
{
30+
public Point Start { get; set; }
31+
32+
public Point End { get; set; }
33+
}
34+
35+
public class Circle : Shape
36+
{
37+
public Point Center { get; set; }
38+
39+
public int Radius { get; set; }
40+
}
41+
42+
public class Rect : Shape
43+
{
44+
public Point TopLeft { get; set; }
45+
46+
public Point BottomRight { get; set; }
47+
}
48+
49+
public class Ellipse : Shape
50+
{
51+
public Point TopLeft { get; set; }
52+
53+
public Point BottomRight { get; set; }
54+
}
55+
56+
public class Diagram
57+
{
58+
private int totalUsers = 0;
59+
60+
public byte[] Background { get; set; }
61+
62+
public string BackgroundContentType { get; set; }
63+
64+
public string BackgroundId { get; set; }
65+
66+
public ConcurrentDictionary<string, Shape> Shapes { get; } = new ConcurrentDictionary<string, Shape>();
67+
68+
public int UserEnter()
69+
{
70+
return Interlocked.Increment(ref totalUsers);
71+
}
72+
73+
public int UserLeave()
74+
{
75+
return Interlocked.Decrement(ref totalUsers);
76+
}
77+
}

samples/Whiteboard/Hub/DrawHub.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using Microsoft.AspNetCore.SignalR;
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
using System.Linq;
8+
using System;
9+
10+
namespace Microsoft.Azure.SignalR.Samples.Whiteboard;
11+
12+
public class DrawHub(Diagram diagram) : Hub
13+
{
14+
private readonly Diagram diagram = diagram;
15+
16+
private async Task UpdateShape(string id, Shape shape)
17+
{
18+
diagram.Shapes[id] = shape;
19+
await Clients.Others.SendAsync("ShapeUpdated", id, shape.GetType().Name, shape);
20+
}
21+
22+
public override Task OnConnectedAsync()
23+
{
24+
var t = Task.WhenAll(diagram.Shapes.AsEnumerable().Select(l => Clients.Client(Context.ConnectionId).SendAsync("ShapeUpdated", l.Key, l.Value.GetType().Name, l.Value)));
25+
if (diagram.Background != null) t = t.ContinueWith(_ => Clients.Client(Context.ConnectionId).SendAsync("BackgroundUpdated", diagram.BackgroundId));
26+
return t.ContinueWith(_ => Clients.All.SendAsync("UserUpdated", diagram.UserEnter()));
27+
}
28+
29+
public override Task OnDisconnectedAsync(Exception exception)
30+
{
31+
return Clients.All.SendAsync("UserUpdated", diagram.UserLeave());
32+
}
33+
34+
public async Task RemoveShape(string id)
35+
{
36+
diagram.Shapes.Remove(id, out _);
37+
await Clients.Others.SendAsync("ShapeRemoved", id);
38+
}
39+
40+
public async Task AddOrUpdatePolyline(string id, Polyline polyline)
41+
{
42+
await this.UpdateShape(id, polyline);
43+
}
44+
45+
public async Task PatchPolyline(string id, Polyline polyline)
46+
{
47+
if (diagram.Shapes[id] is not Polyline p) throw new InvalidOperationException($"Shape {id} does not exist or is not a polyline.");
48+
if (polyline.Color != null) p.Color = polyline.Color;
49+
if (polyline.Width != 0) p.Width = polyline.Width;
50+
p.Points.AddRange(polyline.Points);
51+
await Clients.Others.SendAsync("ShapePatched", id, polyline);
52+
}
53+
54+
public async Task AddOrUpdateLine(string id, Line line)
55+
{
56+
await this.UpdateShape(id, line);
57+
}
58+
59+
public async Task AddOrUpdateCircle(string id, Circle circle)
60+
{
61+
await this.UpdateShape(id, circle);
62+
}
63+
64+
public async Task AddOrUpdateRect(string id, Rect rect)
65+
{
66+
await this.UpdateShape(id, rect);
67+
}
68+
69+
public async Task AddOrUpdateEllipse(string id, Ellipse ellipse)
70+
{
71+
await this.UpdateShape(id, ellipse);
72+
}
73+
74+
public async Task Clear()
75+
{
76+
diagram.Shapes.Clear();
77+
diagram.Background = null;
78+
await Clients.Others.SendAsync("Clear");
79+
}
80+
81+
public async Task SendMessage(string name, string message)
82+
{
83+
await Clients.Others.SendAsync("NewMessage", name, message);
84+
}
85+
}

samples/Whiteboard/MCPServer/index.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3+
import { z } from 'zod';
4+
import { HubConnectionBuilder } from '@microsoft/signalr';
5+
import dotenv from 'dotenv';
6+
7+
dotenv.config();
8+
9+
const logger = new class {
10+
log = (level, message) => level > 1 && console.error(`[${level}] ${message}`);
11+
};
12+
13+
const connection = new HubConnectionBuilder().withUrl(`${process.env['WHITEBOARD_ENDPOINT'] || 'http://localhost:5000'}/draw`).withAutomaticReconnect().configureLogging(logger).build();
14+
15+
const server = new McpServer({
16+
name: 'Whiteboard',
17+
version: '1.0.0'
18+
});
19+
20+
let color = z.string().describe('color of the shape, valid values are: black, grey, darkred, red, orange, yellow, green, deepskyblue, indigo, purple');
21+
let width = z.number().describe('width of the shape, valid values are: 1, 2, 4, 8');
22+
let point = z.object({
23+
x: z.number().describe('x coordinate of the point, 0 denotes the left edge of the whiteboard'),
24+
y: z.number().describe('y coordinate of the point, 0 denotes the top edge of the whiteboard')
25+
});
26+
let id = z.string().describe('unique identifier of the shape, if it does not exist, it will be created, if it exists, it will be updated');
27+
28+
server.tool('send_message', 'post a message on whiteboard', { name: z.string(), message: z.string() }, async ({ name, message }) => {
29+
await connection.send('sendMessage', name, message);
30+
return { content: [{ type: 'text', text: 'Message sent' }] }
31+
});
32+
33+
server.tool(
34+
'add_or_update_polyline', 'add or update a polyline on whiteboard',
35+
{
36+
id, polyline: z.object({
37+
color, width,
38+
points: z.array(point).describe('array of points that define the polyline')
39+
})
40+
},
41+
async ({ id, polyline }) => {
42+
await connection.send('addOrUpdatePolyline', id, polyline);
43+
return { content: [{ type: 'text', text: 'Polyline added or updated' }] };
44+
});
45+
46+
server.tool(
47+
'add_or_update_line', 'add or update a line on whiteboard',
48+
{
49+
id, line: z.object({
50+
color, width,
51+
start: point.describe('start point of the line'),
52+
end: point.describe('end point of the line')
53+
})
54+
},
55+
async ({ id, line }) => {
56+
await connection.send('addOrUpdateLine', id, line);
57+
return { content: [{ type: 'text', text: 'Line added or updated' }] };
58+
});
59+
60+
server.tool(
61+
'add_or_update_circle', 'add or update a circle on whiteboard',
62+
{
63+
id, circle: z.object({
64+
color, width,
65+
center: point.describe('center point of the circle'),
66+
radius: z.number().describe('radius of the circle')
67+
})
68+
},
69+
async ({ id, circle }) => {
70+
await connection.send('addOrUpdateCircle', id, circle);
71+
return { content: [{ type: 'text', text: 'Circle added or updated' }] };
72+
});
73+
74+
server.tool(
75+
'add_or_update_rect', 'add or update a rectangle on whiteboard',
76+
{
77+
id, rect: z.object({
78+
color, width,
79+
topLeft: point.describe('top left corner of the rectangle'),
80+
bottomRight: point.describe('bottom right of the rectangle')
81+
})
82+
},
83+
async ({ id, rect }) => {
84+
await connection.send('addOrUpdateRect', id, rect);
85+
return { content: [{ type: 'text', text: 'Rectangle added or updated' }] };
86+
});
87+
88+
server.tool(
89+
'add_or_update_ellipse', 'add or update an ellipse on whiteboard',
90+
{
91+
id, ellipse: z.object({
92+
color, width,
93+
topLeft: point.describe('top left corner of the bounding rectangle of the ellipse'),
94+
bottomRight: point.describe('bottom right of the bounding rectangle of the ellipse')
95+
})
96+
},
97+
async ({ id, ellipse }) => {
98+
await connection.send('addOrUpdateEllipse', id, ellipse);
99+
return { content: [{ type: 'text', text: 'Ellipse added or updated' }] };
100+
});
101+
102+
server.tool(
103+
'remove_shape', 'remove a shape from whiteboard',
104+
{ id },
105+
async ({ id }) => {
106+
await connection.send('removeShape', id);
107+
return { content: [{ type: 'text', text: 'Shape removed' }] };
108+
});
109+
110+
server.tool(
111+
'clear', 'clear the whiteboard',
112+
{},
113+
async () => {
114+
await connection.send('clear');
115+
return { content: [{ type: 'text', text: 'Whiteboard cleared' }] };
116+
});
117+
118+
const transport = new StdioServerTransport();
119+
120+
await server.connect(transport);
121+
122+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
123+
for (;;) {
124+
try {
125+
await connection.start();
126+
break;
127+
} catch (e) {
128+
console.error('Failed to start SignalR connection: ' + e.message);
129+
await sleep(5000);
130+
}
131+
}

0 commit comments

Comments
 (0)