Skip to content

Commit a8f598e

Browse files
committed
Merge branch feat/decouple and fix all issue and lint
2 parents 29f3561 + b538c04 commit a8f598e

37 files changed

+575
-378
lines changed

.github/workflows/check.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ on:
99
push:
1010
branches:
1111
- master
12-
- 'feat/decouple'
1312
tags-ignore:
1413
- "**" # Ignore all tags to prevent duplicate builds when tags are pushed.
1514

@@ -37,3 +36,6 @@ jobs:
3736

3837
- name: Test
3938
run: npm run test
39+
40+
- name: Type checks
41+
run: npm run type-check

.github/workflows/pre_release.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ on:
66
push:
77
branches:
88
- master
9-
- 'feat/decouple'
109
tags-ignore:
1110
- "**" # Ignore all tags to prevent duplicate builds when tags are pushed.
1211

.nvmrc

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

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
88
### 🚀 Features
99

1010
- Local apify api base url (#71) ([581f096](https://github.com/apify/actors-mcp-server/commit/581f096030018aa9f1052151c5b628d9b186193b))
11+
- Add new specification regarding json response streamable http (#72) ([e918d26](https://github.com/apify/actors-mcp-server/commit/e918d26f224466cca58fa5ed35063c13054f9480))
1112

1213
### 🐛 Bug Fixes
1314

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: 140 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,92 +2,171 @@
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';
13-
import { getActorRunData } from './utils.js';
1415
import { processParamsGetTools } from '../mcp/utils.js';
16+
import { getHelpMessage, HEADER_READINESS_PROBE, Routes } from './const.js';
17+
import { getActorRunData } from './utils.js';
1518

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

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

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

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

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

src/actor/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
export interface ActorRunData {
32
id?: string;
43
actId?: string;

0 commit comments

Comments
 (0)