Skip to content

Commit e918d26

Browse files
authored
feat: add new specification regarding json response streamable http (#72)
* fix: add http request/response, fix lint * feat: add client for /mcp endpoint * fix: fork modelcontextprotocol repository * fix: refactor server.ts
1 parent a9d3b65 commit e918d26

25 files changed

+523
-323
lines changed

.github/workflows/check.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ jobs:
3030
run: npm ci
3131

3232
- name: Lint
33-
run: npm run lint
33+
run: npm run lint:fix
3434

3535
- name: Build
3636
run: npm run build
3737

3838
- name: Test
3939
run: npm run test
40+
41+
- name: Type checks
42+
run: npm run type-check

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
22.13.1

eslint.config.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import apify from '@apify/eslint-config';
1+
import apifyTypeScriptConfig from '@apify/eslint-config/ts.js';
22

33
// eslint-disable-next-line import/no-default-export
44
export default [
5-
{ ignores: ['**/dist', '**/.venv'] }, // Ignores need to happen first
6-
...apify,
5+
{ ignores: ['**/dist'] }, // Ignores need to happen first
6+
...apifyTypeScriptConfig,
77
{
88
languageOptions: {
99
sourceType: 'module',

package-lock.json

Lines changed: 187 additions & 188 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
],
3232
"dependencies": {
3333
"@apify/log": "^2.5.16",
34-
"@modelcontextprotocol/sdk": "^1.9.0",
34+
"@modelcontextprotocol/sdk": "github:jirispilka/mcp-typescript-sdk#fix/add-src-dir",
3535
"ajv": "^8.17.1",
3636
"apify": "^3.4.0",
3737
"apify-client": "^2.12.1",
@@ -59,13 +59,13 @@
5959
"start": "npm run start:dev",
6060
"start:prod": "node dist/main.js",
6161
"start:dev": "tsx src/main.ts",
62-
"lint": "./node_modules/.bin/eslint .",
63-
"lint:fix": "./node_modules/.bin/eslint . --fix",
64-
"build": "tsc",
65-
"build:watch": "tsc -w",
62+
"lint": "eslint .",
63+
"lint:fix": "eslint . --fix",
64+
"build": "tsc -b src",
65+
"build:watch": "tsc -b src -w",
66+
"type-check": "tsc --noEmit",
6667
"inspector": "npx @modelcontextprotocol/inspector dist/stdio.js",
6768
"test": "vitest run",
68-
"type-check": "tsc --noEmit",
6969
"clean": "tsc -b src --clean"
7070
},
7171
"author": "Apify",

src/actor/const.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ export const HEADER_READINESS_PROBE = 'x-apify-container-server-readiness-probe'
55

66
export enum Routes {
77
ROOT = '/',
8+
MCP = '/mcp',
89
SSE = '/sse',
910
MESSAGE = '/message',
1011
}
12+
13+
export const getHelpMessage = (host: string) => `To interact with the server you can either:
14+
- send request to ${host}${Routes.MCP}?token=YOUR-APIFY-TOKEN and receive a response
15+
or
16+
- connect for Server-Sent Events (SSE) via GET request to: ${host}${Routes.SSE}?token=YOUR-APIFY-TOKEN
17+
- send messages via POST request to: ${host}${Routes.MESSAGE}?token=YOUR-APIFY-TOKEN
18+
(Include your message content in the request body.)`;

src/actor/server.ts

Lines changed: 139 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,91 +2,170 @@
22
* Express server implementation used for standby Actor mode.
33
*/
44

5+
import { randomUUID } from 'node:crypto';
6+
57
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
8+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
69
import type { Request, Response } from 'express';
710
import express from 'express';
811

912
import log from '@apify/log';
1013

11-
import { HEADER_READINESS_PROBE, Routes } from './const.js';
1214
import { type ActorsMcpServer } from '../mcp-server.js';
15+
import { getHelpMessage, HEADER_READINESS_PROBE, Routes } from './const.js';
1316
import { getActorRunData, processParamsGetTools } from './utils.js';
1417

1518
export function createExpressApp(
1619
host: string,
1720
mcpServer: ActorsMcpServer,
1821
): express.Express {
19-
const HELP_MESSAGE = `Connect to the server with GET request to ${host}/sse?token=YOUR-APIFY-TOKEN`
20-
+ ` and then send POST requests to ${host}/message?token=YOUR-APIFY-TOKEN`;
21-
2222
const app = express();
23+
app.use(express.json());
24+
let transportSSE: SSEServerTransport;
25+
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
2326

24-
let transport: SSEServerTransport;
27+
function respondWithError(res: Response, error: unknown, logMessage: string, statusCode = 500) {
28+
log.error(`${logMessage}: ${error}`);
29+
if (!res.headersSent) {
30+
res.status(statusCode).json({
31+
jsonrpc: '2.0',
32+
error: {
33+
code: statusCode === 500 ? -32603 : -32000,
34+
message: statusCode === 500 ? 'Internal server error' : 'Bad Request',
35+
},
36+
id: null,
37+
});
38+
}
39+
}
2540

26-
app.route(Routes.ROOT)
27-
.get(async (req: Request, res: Response) => {
28-
if (req.headers && req.get(HEADER_READINESS_PROBE) !== undefined) {
29-
log.debug('Received readiness probe');
30-
res.status(200).json({ message: 'Server is ready' }).end();
31-
return;
41+
app.get(Routes.ROOT, async (req: Request, res: Response) => {
42+
if (req.headers && req.get(HEADER_READINESS_PROBE) !== undefined) {
43+
log.debug('Received readiness probe');
44+
res.status(200).json({ message: 'Server is ready' }).end();
45+
return;
46+
}
47+
try {
48+
log.info(`Received GET message at: ${Routes.ROOT}`);
49+
const tools = await processParamsGetTools(req.url);
50+
if (tools) {
51+
mcpServer.updateTools(tools);
3252
}
33-
try {
34-
log.info(`Received GET message at: ${Routes.ROOT}`);
35-
const tools = await processParamsGetTools(req.url);
36-
if (tools) {
37-
mcpServer.updateTools(tools);
38-
}
39-
res.setHeader('Content-Type', 'text/event-stream');
40-
res.setHeader('Cache-Control', 'no-cache');
41-
res.setHeader('Connection', 'keep-alive');
42-
res.status(200).json({ message: `Actor is using Model Context Protocol. ${HELP_MESSAGE}`, data: getActorRunData() }).end();
43-
} catch (error) {
44-
log.error(`Error in GET ${Routes.ROOT} ${error}`);
45-
res.status(500).json({ message: 'Internal Server Error' }).end();
53+
res.setHeader('Content-Type', 'text/event-stream');
54+
res.setHeader('Cache-Control', 'no-cache');
55+
res.setHeader('Connection', 'keep-alive');
56+
res.status(200).json({ message: `Actor is using Model Context Protocol. ${getHelpMessage(host)}`, data: getActorRunData() }).end();
57+
} catch (error) {
58+
respondWithError(res, error, `Error in GET ${Routes.ROOT}`);
59+
}
60+
});
61+
62+
app.head(Routes.ROOT, (_req: Request, res: Response) => {
63+
res.status(200).end();
64+
});
65+
66+
app.get(Routes.SSE, async (req: Request, res: Response) => {
67+
try {
68+
log.info(`Received GET message at: ${Routes.SSE}`);
69+
const tools = await processParamsGetTools(req.url);
70+
if (tools) {
71+
mcpServer.updateTools(tools);
4672
}
47-
})
48-
.head((_req: Request, res: Response) => {
49-
res.status(200).end();
50-
});
51-
52-
app.route(Routes.SSE)
53-
.get(async (req: Request, res: Response) => {
54-
try {
55-
log.info(`Received GET message at: ${Routes.SSE}`);
56-
const tools = await processParamsGetTools(req.url);
57-
if (tools) {
58-
mcpServer.updateTools(tools);
59-
}
60-
transport = new SSEServerTransport(Routes.MESSAGE, res);
61-
await mcpServer.connect(transport);
62-
} catch (error) {
63-
log.error(`Error in GET ${Routes.SSE}: ${error}`);
64-
res.status(500).json({ message: 'Internal Server Error' }).end();
73+
transportSSE = new SSEServerTransport(Routes.MESSAGE, res);
74+
await mcpServer.connect(transportSSE);
75+
} catch (error) {
76+
respondWithError(res, error, `Error in GET ${Routes.SSE}`);
77+
}
78+
});
79+
80+
app.post(Routes.MESSAGE, async (req: Request, res: Response) => {
81+
try {
82+
log.info(`Received POST message at: ${Routes.MESSAGE}`);
83+
if (transportSSE) {
84+
await transportSSE.handlePostMessage(req, res);
85+
} else {
86+
log.error('Server is not connected to the client.');
87+
res.status(400).json({
88+
jsonrpc: '2.0',
89+
error: {
90+
code: -32000,
91+
message: 'Bad Request: Server is not connected to the client. '
92+
+ 'Connect to the server with GET request to /sse endpoint',
93+
},
94+
id: null,
95+
});
6596
}
66-
});
67-
68-
app.route(Routes.MESSAGE)
69-
.post(async (req: Request, res: Response) => {
70-
try {
71-
log.info(`Received POST message at: ${Routes.MESSAGE}`);
72-
if (transport) {
73-
await transport.handlePostMessage(req, res);
74-
} else {
75-
res.status(400).json({
76-
message: 'Server is not connected to the client. '
77-
+ 'Connect to the server with GET request to /sse endpoint',
78-
});
97+
} catch (error) {
98+
respondWithError(res, error, `Error in POST ${Routes.MESSAGE}`);
99+
}
100+
});
101+
102+
app.post(Routes.MCP, async (req: Request, res: Response) => {
103+
log.info('Received MCP request:', req.body);
104+
try {
105+
// Check for existing session ID
106+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
107+
let transport: StreamableHTTPServerTransport;
108+
109+
if (sessionId && transports[sessionId]) {
110+
// Reuse existing transport
111+
transport = transports[sessionId];
112+
} else if (!sessionId && isInitializeRequest(req.body)) {
113+
// New initialization request - use JSON response mode
114+
transport = new StreamableHTTPServerTransport({
115+
sessionIdGenerator: () => randomUUID(),
116+
enableJsonResponse: true, // Enable JSON response mode
117+
});
118+
119+
// Connect the transport to the MCP server BEFORE handling the request
120+
await mcpServer.connect(transport);
121+
122+
// After handling the request, if we get a session ID back, store the transport
123+
await transport.handleRequest(req, res, req.body);
124+
125+
// Store the transport by session ID for future requests
126+
if (transport.sessionId) {
127+
transports[transport.sessionId] = transport;
79128
}
80-
} catch (error) {
81-
log.error(`Error in POST ${Routes.MESSAGE}: ${error}`);
82-
res.status(500).json({ message: 'Internal Server Error' }).end();
129+
return; // Already handled
130+
} else {
131+
// Invalid request - no session ID or not initialization request
132+
res.status(400).json({
133+
jsonrpc: '2.0',
134+
error: {
135+
code: -32000,
136+
message: 'Bad Request: No valid session ID provided or not initialization request',
137+
},
138+
id: null,
139+
});
140+
return;
83141
}
84-
});
142+
143+
// Handle the request with existing transport - no need to reconnect
144+
await transport.handleRequest(req, res, req.body);
145+
} catch (error) {
146+
respondWithError(res, error, 'Error handling MCP request');
147+
}
148+
});
149+
150+
// Handle GET requests for SSE streams according to spec
151+
app.get(Routes.MCP, async (_req: Request, res: Response) => {
152+
// We don't support GET requests for this server
153+
// The spec requires returning 405 Method Not Allowed in this case
154+
res.status(405).set('Allow', 'POST').send('Method Not Allowed');
155+
});
85156

86157
// Catch-all for undefined routes
87158
app.use((req: Request, res: Response) => {
88-
res.status(404).json({ message: `There is nothing at route ${req.method} ${req.originalUrl}. ${HELP_MESSAGE}` }).end();
159+
res.status(404).json({ message: `There is nothing at route ${req.method} ${req.originalUrl}. ${getHelpMessage(host)}` }).end();
89160
});
90161

91162
return app;
92163
}
164+
165+
// Helper function to detect initialize requests
166+
function isInitializeRequest(body: unknown): boolean {
167+
if (Array.isArray(body)) {
168+
return body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize');
169+
}
170+
return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize';
171+
}

src/actor/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { parse } from 'node:querystring';
22

33
import { Actor } from 'apify';
44

5-
import { processInput } from './input.js';
6-
import type { ActorRunData, Input } from './types.js';
75
import { addTool, getActorsAsTools, removeTool } from '../tools/index.js';
86
import type { ToolWrap } from '../types.js';
7+
import { processInput } from './input.js';
8+
import type { ActorRunData, Input } from './types.js';
99

1010
export function parseInputParamsFromUrl(url: string): Input {
1111
const query = url.split('?')[1] || '';

src/const.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const ACTOR_MAX_MEMORY_MBYTES = 4_096; // If the Actor requires 8GB of me
1616

1717
// MCP Server
1818
export const SERVER_NAME = 'apify-mcp-server';
19-
export const SERVER_VERSION = '0.1.0';
19+
export const SERVER_VERSION = '1.0.0';
2020

2121
// User agent headers
2222
export const USER_AGENT_ORIGIN = 'Origin/mcp-server';

src/examples/clientSse.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
* It requires the `APIFY_TOKEN` in the `.env` file.
77
*/
88

9-
import path from 'path';
10-
import { fileURLToPath } from 'url';
9+
import path from 'node:path';
10+
import { fileURLToPath } from 'node:url';
1111

1212
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
1313
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
1414
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
15-
import dotenv from 'dotenv';
16-
import { EventSource, EventSourceInit } from 'eventsource';
15+
import dotenv from 'dotenv'; // eslint-disable-line import/no-extraneous-dependencies
16+
import type { EventSourceInit } from 'eventsource';
17+
import { EventSource } from 'eventsource'; // eslint-disable-line import/no-extraneous-dependencies
1718

1819
import { actorNameToToolName } from '../tools/utils.js';
1920

@@ -36,13 +37,15 @@ if (!process.env.APIFY_TOKEN) {
3637

3738
// Declare EventSource on globalThis if not available (needed for Node.js environment)
3839
declare global {
40+
41+
// eslint-disable-next-line no-var, vars-on-top
3942
var EventSource: {
4043
new(url: string, eventSourceInitDict?: EventSourceInit): EventSource;
4144
prototype: EventSource;
4245
CONNECTING: 0;
4346
OPEN: 1;
4447
CLOSED: 2;
45-
}; // eslint-disable-line no-var
48+
};
4649
}
4750

4851
if (typeof globalThis.EventSource === 'undefined') {

0 commit comments

Comments
 (0)