Skip to content

Commit 161d70f

Browse files
committed
feat: specify the path to listen to mcp requests on
1 parent e097f0d commit 161d70f

10 files changed

Lines changed: 253 additions & 33 deletions

File tree

examples/private_mcp_server/src/main.mo

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { thash } "mo:map/Map";
33
import Result "mo:base/Result";
44
import Blob "mo:base/Blob";
55
import Principal "mo:base/Principal";
6+
import Text "mo:base/Text";
67
import Json "mo:json";
78
import HttpTypes "mo:http-types";
89

@@ -147,17 +148,65 @@ shared persistent actor class McpServer() = self {
147148
streaming_callback = http_request_streaming_callback;
148149
auth = ?authContext;
149150
http_asset_cache = ?http_assets.cache;
151+
mcp_path = ?"/mcp"; // All MCP requests must start with /mcp
150152
};
151153
};
152154

153155
public query func http_request(req : SrvTypes.HttpRequest) : async SrvTypes.HttpResponse {
154156
let ctx : HttpHandler.Context = _create_http_context();
155-
return HttpHandler.http_request(ctx, req);
157+
// Ask the SDK to handle the request
158+
switch (HttpHandler.http_request(ctx, req)) {
159+
case (?mcpResponse) {
160+
// The SDK handled it, so we return its response.
161+
return mcpResponse;
162+
};
163+
case (null) {
164+
// The SDK ignored it. Now we can handle our own custom routes.
165+
if (req.url == "/") {
166+
// e.g., Serve a frontend asset
167+
return {
168+
status_code = 200;
169+
headers = [("Content-Type", "text/html")];
170+
body = Text.encodeUtf8("<h1>My Canister Frontend</h1>");
171+
upgrade = null;
172+
streaming_strategy = null;
173+
};
174+
} else {
175+
// Return a 404 for any other unhandled routes.
176+
return {
177+
status_code = 404;
178+
headers = [];
179+
body = Blob.fromArray([]);
180+
upgrade = null;
181+
streaming_strategy = null;
182+
};
183+
};
184+
};
185+
};
156186
};
157187

158-
public func http_request_update(req : SrvTypes.HttpRequest) : async SrvTypes.HttpResponse {
188+
public shared func http_request_update(req : SrvTypes.HttpRequest) : async SrvTypes.HttpResponse {
159189
let ctx : HttpHandler.Context = _create_http_context();
160-
return await HttpHandler.http_request_update(ctx, req);
190+
191+
// Ask the SDK to handle the request
192+
let mcpResponse = await HttpHandler.http_request_update(ctx, req);
193+
194+
switch (mcpResponse) {
195+
case (?res) {
196+
// The SDK handled it.
197+
return res;
198+
};
199+
case (null) {
200+
// The SDK ignored it. Handle custom update calls here.
201+
return {
202+
status_code = 404;
203+
headers = [];
204+
body = Blob.fromArray([]);
205+
upgrade = null;
206+
streaming_strategy = null;
207+
};
208+
};
209+
};
161210
};
162211

163212
public query func http_request_streaming_callback(token : HttpTypes.StreamingToken) : async ?HttpTypes.StreamingCallbackResponse {

examples/public_mcp_server/src/main.mo

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import Map "mo:map/Map";
22
import { thash } "mo:map/Map";
33
import Result "mo:base/Result";
44
import Principal "mo:base/Principal";
5+
import Text "mo:base/Text";
6+
import Blob "mo:base/Blob";
57
import Json "mo:json";
68
import HttpTypes "mo:http-types";
79

@@ -111,17 +113,65 @@ shared persistent actor class McpServer() = self {
111113
streaming_callback = http_request_streaming_callback;
112114
auth = null;
113115
http_asset_cache = null;
116+
mcp_path = ?"/mcp"; // All MCP requests must start with /mcp
114117
};
115118
};
116119

117120
public query func http_request(req : SrvTypes.HttpRequest) : async SrvTypes.HttpResponse {
118121
let ctx : HttpHandler.Context = _create_http_context();
119-
return HttpHandler.http_request(ctx, req);
122+
// Ask the SDK to handle the request
123+
switch (HttpHandler.http_request(ctx, req)) {
124+
case (?mcpResponse) {
125+
// The SDK handled it, so we return its response.
126+
return mcpResponse;
127+
};
128+
case (null) {
129+
// The SDK ignored it. Now we can handle our own custom routes.
130+
if (req.url == "/") {
131+
// e.g., Serve a frontend asset
132+
return {
133+
status_code = 200;
134+
headers = [("Content-Type", "text/html")];
135+
body = Text.encodeUtf8("<h1>My Canister Frontend</h1>");
136+
upgrade = null;
137+
streaming_strategy = null;
138+
};
139+
} else {
140+
// Return a 404 for any other unhandled routes.
141+
return {
142+
status_code = 404;
143+
headers = [];
144+
body = Blob.fromArray([]);
145+
upgrade = null;
146+
streaming_strategy = null;
147+
};
148+
};
149+
};
150+
};
120151
};
121152

122-
public func http_request_update(req : SrvTypes.HttpRequest) : async SrvTypes.HttpResponse {
153+
public shared func http_request_update(req : SrvTypes.HttpRequest) : async SrvTypes.HttpResponse {
123154
let ctx : HttpHandler.Context = _create_http_context();
124-
return await HttpHandler.http_request_update(ctx, req);
155+
156+
// Ask the SDK to handle the request
157+
let mcpResponse = await HttpHandler.http_request_update(ctx, req);
158+
159+
switch (mcpResponse) {
160+
case (?res) {
161+
// The SDK handled it.
162+
return res;
163+
};
164+
case (null) {
165+
// The SDK ignored it. Handle custom update calls here.
166+
return {
167+
status_code = 404;
168+
headers = [];
169+
body = Blob.fromArray([]);
170+
upgrade = null;
171+
streaming_strategy = null;
172+
};
173+
};
174+
};
125175
};
126176

127177
public query func http_request_streaming_callback(token : HttpTypes.StreamingToken) : async ?HttpTypes.StreamingCallbackResponse {

src/mcp/HttpHandler.mo

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ module {
3333
auth : ?AuthTypes.AuthContext;
3434
// The HTTP asset cache, if configured.
3535
http_asset_cache : ?CertifiedCache.CertifiedCache<Text, Blob>;
36+
// The base path for all MCP requests, e.g., "/mcp".
37+
// The SDK will ignore any requests that do not start with this path.
38+
mcp_path : ?Text;
3639
};
3740

3841
// Helper function to determine if a request is for a streaming response.
@@ -65,7 +68,7 @@ module {
6568
};
6669

6770
// The public entry point for query calls.
68-
public func http_request(ctx : Context, req : SrvTypes.HttpRequest) : SrvTypes.HttpResponse {
71+
public func http_request(ctx : Context, req : SrvTypes.HttpRequest) : ?SrvTypes.HttpResponse {
6972
if (req.method == "GET" and Text.contains(req.url, #text "/.well-known/oauth-protected-resource")) {
7073
switch (ctx.http_asset_cache) {
7174
case (?cache) {
@@ -94,7 +97,7 @@ module {
9497
switch (cache.get(clean_path)) {
9598
case (?bodyBlob) {
9699
// CACHE HIT: The library handles everything.
97-
return {
100+
return ?{
98101
status_code = 200;
99102
headers = [
100103
("Content-Type", "application/json"),
@@ -107,7 +110,7 @@ module {
107110
};
108111
case (null) {
109112
// CACHE MISS: Instruct the client to upgrade.
110-
return {
113+
return ?{
111114
status_code = 204;
112115
headers = [];
113116
body = Blob.fromArray([]);
@@ -121,6 +124,13 @@ module {
121124
};
122125
};
123126

127+
// --- 2. Check if the request is for the configured MCP path ---
128+
let mcpUrl = Option.get(ctx.mcp_path, "/mcp");
129+
if (not Text.startsWith(req.url, #text mcpUrl)) {
130+
// This is not an MCP request. Signal the caller to handle it.
131+
return null;
132+
};
133+
124134
if (req.method == "GET" and is_streaming_request(req)) {
125135
// Handle the streaming handshake for clients like the MCP Inspector.
126136
let token_blob = Blob.fromArray(Utils.nat64ToBytes(Nat64.fromIntWrap(Time.now())));
@@ -132,7 +142,7 @@ module {
132142
token = token_blob;
133143
});
134144

135-
return {
145+
return ?{
136146
status_code = 200;
137147
headers = [("Content-Type", "text/event-stream")];
138148
body = Blob.fromArray([]);
@@ -144,7 +154,7 @@ module {
144154
// For any other request, we don't handle it here. We immediately
145155
// instruct the client to upgrade to an update call. This ensures
146156
// all responses are certified via consensus.
147-
return {
157+
return ?{
148158
status_code = 204; // 204 No Content is a standard way to signal an upgrade.
149159
headers = [];
150160
body = Blob.fromArray([]);
@@ -155,7 +165,7 @@ module {
155165
};
156166

157167
// The public entry point for update calls.
158-
public func http_request_update(ctx : Context, req : SrvTypes.HttpRequest) : async SrvTypes.HttpResponse {
168+
public func http_request_update(ctx : Context, req : SrvTypes.HttpRequest) : async ?SrvTypes.HttpResponse {
159169
// All MCP logic is now routed through here, ensuring responses are certified.
160170

161171
// --- Intercept metadata requests to perform certification ---
@@ -169,7 +179,7 @@ module {
169179
cache.put(req.url, bodyBlob, null);
170180

171181
// 3. Return a simple, uncertified 200 OK.
172-
return {
182+
return ?{
173183
status_code = 200;
174184
headers = [("Content-Type", "application/json")];
175185
body = bodyBlob;
@@ -181,6 +191,13 @@ module {
181191
};
182192
};
183193

194+
// --- 2. Check if the request is for the configured MCP path ---
195+
let mcpUrl = Option.get(ctx.mcp_path, "/mcp");
196+
if (not Text.startsWith(req.url, #text mcpUrl)) {
197+
// This is not an MCP request. Signal the caller to handle it.
198+
return null;
199+
};
200+
184201
// Check if authentication is configured on the server.
185202
switch (ctx.auth) {
186203
case (?authCtx) {
@@ -192,18 +209,18 @@ module {
192209
switch (authResult) {
193210
case (#err(httpResponse)) {
194211
// Auth failed, return the error response immediately.
195-
return httpResponse;
212+
return ?httpResponse;
196213
};
197214
case (#ok(authInfo)) {
198215
// Auth succeeded, handle the request with the trusted auth info.
199-
return await ctx.mcp_server.handle_request(req, ?authInfo);
216+
return ?(await ctx.mcp_server.handle_request(req, ?authInfo));
200217
};
201218
};
202219
};
203220
case (_) {
204221
// --- AUTH IS OFF ---
205222
// No auth config, so proceed without authentication.
206-
return await ctx.mcp_server.handle_request(req, null);
223+
return ?(await ctx.mcp_server.handle_request(req, null));
207224
};
208225
};
209226
};

test/e2e/auth.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const mockAuthServerUrl = process.env.E2E_MOCK_AUTH_SERVER_URL!;
1414
// --- Test State ---
1515
let jwtPrivateKey: jose.CryptoKey;
1616

17+
const mcpPath = '/mcp';
1718
const resourceServerUrl = new URL(replicaUrl);
1819
resourceServerUrl.searchParams.set('canisterId', canisterId);
1920

@@ -30,7 +31,7 @@ describe('MCP Authentication and Discovery', () => {
3031
test('should return a 401 with a correct WWW-Authenticate header for unauthenticated requests', async () => {
3132
// ARRANGE
3233
const payload = { jsonrpc: '2.0', method: 'tools/call', params: { name: 'get_weather', arguments: { location: 'Tokyo' } }, id: 'www-auth-test' };
33-
const rpcUrl = new URL(replicaUrl);
34+
const rpcUrl = new URL(mcpPath, replicaUrl);
3435
rpcUrl.searchParams.set('canisterId', canisterId);
3536

3637
// The canister should construct the metadata URL based on the request's URL.
@@ -57,7 +58,7 @@ describe('MCP Authentication and Discovery', () => {
5758
test('should perform the full auth discovery flow on an unauthenticated request', async () => {
5859
// ARRANGE: A standard protected tool call payload
5960
const payload = { jsonrpc: '2.0', method: 'tools/call', params: { name: 'get_weather', arguments: { location: 'Tokyo' } }, id: 'discovery-test' };
60-
const rpcUrl = new URL(replicaUrl);
61+
const rpcUrl = new URL(mcpPath, replicaUrl);
6162
rpcUrl.searchParams.set('canisterId', canisterId);
6263

6364
// STEP 1: Make an unauthenticated call and expect a 401
@@ -126,7 +127,7 @@ describe('MCP Authentication and Discovery', () => {
126127
.sign(jwtPrivateKey);
127128

128129
const payload = { jsonrpc: '2.0', method: 'tools/call', params: { name: 'get_weather', arguments: { location: 'Tokyo' } }, id: 1 };
129-
const url = new URL(replicaUrl);
130+
const url = new URL(mcpPath, replicaUrl);
130131
url.searchParams.set('canisterId', canisterId);
131132

132133
// Act

test/e2e/lifecycle.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dotenv.config({ path: path.resolve(__dirname, '.test.env') });
88
// --- Test Configuration ---
99
const canisterId = process.env.E2E_CANISTER_ID_PUBLIC!;
1010
const replicaUrl = process.env.E2E_REPLICA_URL!;
11+
const mcpPath = '/mcp';
1112

1213
// Helper to create a valid JSON-RPC request payload.
1314
const createRpcPayload = (method: string, params: any, id: number) => ({
@@ -43,7 +44,7 @@ describe('MCP Lifecycle', () => {
4344
const payload = createRpcPayload('initialize', initializeParams, 1);
4445

4546
// Construct the fetch URL and options
46-
const url = new URL(replicaUrl);
47+
const url = new URL(mcpPath, replicaUrl);
4748
url.searchParams.set('canisterId', canisterId);
4849

4950
// Act: Send the request to the canister.
@@ -78,7 +79,7 @@ describe('MCP Lifecycle', () => {
7879
// No 'id' or 'params' for a notification
7980
};
8081

81-
const url = new URL(replicaUrl);
82+
const url = new URL(mcpPath, replicaUrl);
8283
url.searchParams.set('canisterId', canisterId);
8384

8485
// Act: Send the notification.

0 commit comments

Comments
 (0)