Skip to content

Commit 28d5621

Browse files
MQ37mbaiza27mbaiza27jirispilka
authored
feat: Actorized MCP servers proxy (#69)
* docs: document input schema processing (#58) document Actor input schema processing in README * add VS Code instructions to README (#62) * Add vs code instructions --------- Co-authored-by: mbaiza27 <[email protected]> Co-authored-by: Jiří Spilka <[email protected]> * initial working draft of proxy actorized mcp servers * fix tests * fix proxy tool name handling * ci: run prerelase manually (#70) run prerelase manually * organize, fix passing of apify token * fix env var name * fix imports * get standby url from actor id, check if is mcp server based on actor name for now * Actor MCP server load default Actors * get standbyUrlBase from env var * fix standby url base, use dns friendly owner name * get mcp path from definition * Remove unused import from actor.ts * console.log to logger * Remove extra slash from MCP server URL construction * Simplify actor tools loading logic * Refactor actorOwnerDNSFriendly to handle special characters * Add TODO comment for reworking actor definition fetch logic * refactor mcp actor and utils, use real Actor ID as standby URL * fix: get-actor-definition-default-build (#73) * fix get default build in get actor definition * fix tests * fix double import, lint --------- Co-authored-by: Marc Baiza <[email protected]> Co-authored-by: mbaiza27 <[email protected]> Co-authored-by: Jiří Spilka <[email protected]> Co-authored-by: Jiri Spilka <[email protected]>
1 parent b538c04 commit 28d5621

22 files changed

+407
-77
lines changed

src/actor/server.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import express from 'express';
1111

1212
import log from '@apify/log';
1313

14-
import { type ActorsMcpServer } from '../mcp-server.js';
14+
import { type ActorsMcpServer } from '../mcp/server.js';
15+
import { processParamsGetTools } from '../mcp/utils.js';
1516
import { getHelpMessage, HEADER_READINESS_PROBE, Routes } from './const.js';
16-
import { getActorRunData, processParamsGetTools } from './utils.js';
17+
import { getActorRunData } from './utils.js';
1718

1819
export function createExpressApp(
1920
host: string,
@@ -46,7 +47,7 @@ export function createExpressApp(
4647
}
4748
try {
4849
log.info(`Received GET message at: ${Routes.ROOT}`);
49-
const tools = await processParamsGetTools(req.url);
50+
const tools = await processParamsGetTools(req.url, process.env.APIFY_TOKEN as string);
5051
if (tools) {
5152
mcpServer.updateTools(tools);
5253
}
@@ -66,7 +67,7 @@ export function createExpressApp(
6667
app.get(Routes.SSE, async (req: Request, res: Response) => {
6768
try {
6869
log.info(`Received GET message at: ${Routes.SSE}`);
69-
const tools = await processParamsGetTools(req.url);
70+
const tools = await processParamsGetTools(req.url, process.env.APIFY_TOKEN as string);
7071
if (tools) {
7172
mcpServer.updateTools(tools);
7273
}

src/actor/types.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,3 @@
1-
export type Input = {
2-
actors: string[] | string;
3-
enableActorAutoLoading?: boolean;
4-
maxActorMemoryBytes?: number;
5-
debugActor?: string;
6-
debugActorInput?: unknown;
7-
};
8-
91
export interface ActorRunData {
102
id?: string;
113
actId?: string;

src/actor/utils.ts

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,6 @@
1-
import { parse } from 'node:querystring';
2-
31
import { Actor } from 'apify';
42

5-
import { addTool, getActorsAsTools, removeTool } from '../tools/index.js';
6-
import type { ToolWrap } from '../types.js';
7-
import { processInput } from './input.js';
8-
import type { ActorRunData, Input } from './types.js';
9-
10-
export function parseInputParamsFromUrl(url: string): Input {
11-
const query = url.split('?')[1] || '';
12-
const params = parse(query) as unknown as Input;
13-
return processInput(params);
14-
}
15-
16-
/**
17-
* Process input parameters and get tools
18-
* If URL contains query parameter `actors`, return tools from Actors otherwise return null.
19-
* @param url
20-
*/
21-
export async function processParamsGetTools(url: string) {
22-
const input = parseInputParamsFromUrl(url);
23-
let tools: ToolWrap[] = [];
24-
if (input.actors) {
25-
tools = await getActorsAsTools(input.actors as string[]);
26-
}
27-
if (input.enableActorAutoLoading) {
28-
tools.push(addTool, removeTool);
29-
}
30-
return tools;
31-
}
3+
import type { ActorRunData } from './types.js';
324

335
export function getActorRunData(): ActorRunData | null {
346
return Actor.isAtHome() ? {

src/tools/mcp-apify-client.ts renamed to src/apify-client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ApifyClientOptions } from 'apify';
22
import { ApifyClient as _ApifyClient } from 'apify-client';
33
import type { AxiosRequestConfig } from 'axios';
44

5-
import { USER_AGENT_ORIGIN } from '../const.js';
5+
import { USER_AGENT_ORIGIN } from './const.js';
66

77
/**
88
* Adds a User-Agent header to the request config.
@@ -16,11 +16,15 @@ function addUserAgent(config: AxiosRequestConfig): AxiosRequestConfig {
1616
return updatedConfig;
1717
}
1818

19+
export function getApifyAPIBaseUrl(): string {
20+
return process.env.APIFY_API_BASE_URL || 'https://api.apify.com';
21+
}
22+
1923
export class ApifyClient extends _ApifyClient {
2024
constructor(options: ApifyClientOptions) {
2125
super({
2226
...options,
23-
baseUrl: process.env.MCP_APIFY_BASE_URL || undefined,
27+
baseUrl: getApifyAPIBaseUrl(),
2428
requestInterceptors: [addUserAgent],
2529
});
2630
}

src/const.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,5 @@ export const defaults = {
3737
enableActorAutoLoading: false,
3838
maxMemoryMbytes: 4096,
3939
};
40+
41+
export const APIFY_USERNAME = 'apify';

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
The ActorsMcpServer should be the only class exported from the package
44
*/
55

6-
import { ActorsMcpServer } from './mcp-server.js';
6+
import { ActorsMcpServer } from './mcp/server.js';
77

88
export { ActorsMcpServer };
File renamed without changes.

src/main.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import type { ActorCallOptions } from 'apify-client';
88

99
import log from '@apify/log';
1010

11-
import { processInput } from './actor/input.js';
1211
import { createExpressApp } from './actor/server.js';
13-
import type { Input } from './actor/types';
14-
import { ActorsMcpServer } from './mcp-server.js';
15-
import { actorDefinitionTool, addTool, callActorGetDataset, removeTool, searchTool } from './tools/index.js';
12+
import { defaults } from './const.js';
13+
import { processInput } from './input.js';
14+
import { ActorsMcpServer } from './mcp/server.js';
15+
import { actorDefinitionTool, addTool, callActorGetDataset, getActorsAsTools, removeTool, searchTool } from './tools/index.js';
16+
import type { Input } from './types.js';
1617

1718
const STANDBY_MODE = Actor.getEnv().metaOrigin === 'STANDBY';
1819

@@ -38,6 +39,10 @@ if (STANDBY_MODE) {
3839
if (input.enableActorAutoLoading) {
3940
tools.push(addTool, removeTool);
4041
}
42+
const actors = input.actors ?? defaults.actors;
43+
const actorsToLoad = Array.isArray(actors) ? actors : actors.split(',');
44+
const actorTools = await getActorsAsTools(actorsToLoad, process.env.APIFY_TOKEN as string);
45+
tools.push(...actorTools);
4146
mcpServer.updateTools(tools);
4247
app.listen(PORT, () => {
4348
log.info(`The Actor web server is listening for user requests at ${HOST}`);

src/mcp/actors.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { ActorDefinition } from 'apify-client';
2+
3+
import { ApifyClient, getApifyAPIBaseUrl } from '../apify-client.js';
4+
5+
export async function isActorMCPServer(actorID: string, apifyToken: string): Promise<boolean> {
6+
const mcpPath = await getActorsMCPServerPath(actorID, apifyToken);
7+
return (mcpPath?.length || 0) > 0;
8+
}
9+
10+
export async function getActorsMCPServerPath(actorID: string, apifyToken: string): Promise<string | undefined> {
11+
const actorDefinition = await getActorDefinition(actorID, apifyToken);
12+
13+
if ('webServerMcpPath' in actorDefinition && typeof actorDefinition.webServerMcpPath === 'string') {
14+
return actorDefinition.webServerMcpPath;
15+
}
16+
17+
return undefined;
18+
}
19+
20+
export async function getActorsMCPServerURL(actorID: string, apifyToken: string): Promise<string> {
21+
// TODO: get from API instead
22+
const standbyBaseUrl = process.env.HOSTNAME === 'mcp-securitybyobscurity.apify.com'
23+
? 'securitybyobscurity.apify.actor' : 'apify.actor';
24+
const standbyUrl = await getActorStandbyURL(actorID, apifyToken, standbyBaseUrl);
25+
const mcpPath = await getActorsMCPServerPath(actorID, apifyToken);
26+
return `${standbyUrl}${mcpPath}`;
27+
}
28+
29+
/**
30+
* Gets Actor ID from the Actor object.
31+
*
32+
* @param actorID
33+
* @param apifyToken
34+
*/
35+
export async function getRealActorID(actorID: string, apifyToken: string): Promise<string> {
36+
const apifyClient = new ApifyClient({ token: apifyToken });
37+
38+
const actor = apifyClient.actor(actorID);
39+
const info = await actor.get();
40+
if (!info) {
41+
throw new Error(`Actor ${actorID} not found`);
42+
}
43+
return info.id;
44+
}
45+
46+
/**
47+
* Returns standby URL for given Actor ID.
48+
*
49+
* @param actorID
50+
* @param standbyBaseUrl
51+
* @param apifyToken
52+
* @returns
53+
*/
54+
export async function getActorStandbyURL(actorID: string, apifyToken: string, standbyBaseUrl = 'apify.actor'): Promise<string> {
55+
const actorRealID = await getRealActorID(actorID, apifyToken);
56+
return `https://${actorRealID}.${standbyBaseUrl}`;
57+
}
58+
59+
export async function getActorDefinition(actorID: string, apifyToken: string): Promise<ActorDefinition> {
60+
const apifyClient = new ApifyClient({ token: apifyToken });
61+
const actor = apifyClient.actor(actorID);
62+
const info = await actor.get();
63+
if (!info) {
64+
throw new Error(`Actor ${actorID} not found`);
65+
}
66+
67+
const actorObjID = info.id;
68+
const res = await fetch(`${getApifyAPIBaseUrl()}/v2/acts/${actorObjID}/builds/default`, {
69+
headers: {
70+
// This is done so tests can pass with public Actors without token
71+
...(apifyToken ? { Authorization: `Bearer ${apifyToken}` } : {}),
72+
},
73+
});
74+
if (!res.ok) {
75+
throw new Error(`Failed to fetch default build for actor ${actorID}: ${res.statusText}`);
76+
}
77+
const json = await res.json() as any; // eslint-disable-line @typescript-eslint/no-explicit-any
78+
const buildInfo = json.data;
79+
if (!buildInfo) {
80+
throw new Error(`Default build for Actor ${actorID} not found`);
81+
}
82+
const { actorDefinition } = buildInfo;
83+
if (!actorDefinition) {
84+
throw new Error(`Actor default build ${actorID} does not have Actor definition`);
85+
}
86+
87+
return actorDefinition;
88+
}

src/mcp/client.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
3+
4+
import { getMCPServerID } from './utils.js';
5+
6+
/**
7+
* Creates and connects a ModelContextProtocol client.
8+
*/
9+
export async function createMCPClient(
10+
url: string, token: string,
11+
): Promise<Client> {
12+
const transport = new SSEClientTransport(
13+
new URL(url),
14+
{
15+
requestInit: {
16+
headers: {
17+
authorization: `Bearer ${token}`,
18+
},
19+
},
20+
eventSourceInit: {
21+
// The EventSource package augments EventSourceInit with a "fetch" parameter.
22+
// You can use this to set additional headers on the outgoing request.
23+
// Based on this example: https://github.com/modelcontextprotocol/typescript-sdk/issues/118
24+
async fetch(input: Request | URL | string, init?: RequestInit) {
25+
const headers = new Headers(init?.headers || {});
26+
headers.set('authorization', `Bearer ${token}`);
27+
return fetch(input, { ...init, headers });
28+
},
29+
// We have to cast to "any" to use it, since it's non-standard
30+
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
31+
});
32+
33+
const client = new Client({
34+
name: getMCPServerID(url),
35+
version: '1.0.0',
36+
});
37+
38+
await client.connect(transport);
39+
40+
return client;
41+
}

0 commit comments

Comments
 (0)