Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3f27a38
docs: document input schema processing (#58)
MQ37 Apr 1, 2025
5dd679c
add VS Code instructions to README (#62)
mbaiza27 Apr 10, 2025
9a0846b
initial working draft of proxy actorized mcp servers
MQ37 Apr 14, 2025
b8b3ce3
fix tests
MQ37 Apr 14, 2025
3543a5e
fix proxy tool name handling
MQ37 Apr 14, 2025
6c488dd
ci: run prerelase manually (#70)
MQ37 Apr 14, 2025
8712ffd
Merge remote-tracking branch 'origin/master' into feat/actorized-mcp
MQ37 Apr 14, 2025
b91a305
Merge remote-tracking branch 'origin/feat/decouple' into feat/actoriz…
MQ37 Apr 15, 2025
c273cfb
organize, fix passing of apify token
MQ37 Apr 15, 2025
e005b65
fix env var name
MQ37 Apr 15, 2025
0bf2e0c
fix imports
MQ37 Apr 15, 2025
f14025d
get standby url from actor id, check if is mcp server based on actor …
MQ37 Apr 15, 2025
caaebb5
Actor MCP server load default Actors
MQ37 Apr 15, 2025
c2caf6c
get standbyUrlBase from env var
MQ37 Apr 15, 2025
364c0c4
fix standby url base, use dns friendly owner name
MQ37 Apr 15, 2025
69c4327
get mcp path from definition
MQ37 Apr 15, 2025
3c92717
Remove unused import from actor.ts
MQ37 Apr 15, 2025
1c3dc7a
console.log to logger
MQ37 Apr 15, 2025
2f6dec6
Remove extra slash from MCP server URL construction
MQ37 Apr 15, 2025
46ef186
Simplify actor tools loading logic
MQ37 Apr 15, 2025
c39b5ef
Refactor actorOwnerDNSFriendly to handle special characters
MQ37 Apr 15, 2025
7703eba
Add TODO comment for reworking actor definition fetch logic
MQ37 Apr 15, 2025
29f3561
refactor mcp actor and utils, use real Actor ID as standby URL
MQ37 Apr 16, 2025
a8f598e
Merge branch feat/decouple and fix all issue and lint
jirispilka Apr 16, 2025
3292609
fix: get-actor-definition-default-build (#73)
MQ37 Apr 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/actor/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import express from 'express';
import log from '@apify/log';

import { HEADER_READINESS_PROBE, Routes } from './const.js';
import { type ActorsMcpServer } from '../mcp-server.js';
import { getActorRunData, processParamsGetTools } from './utils.js';
import { type ActorsMcpServer } from '../mcp/server.js';
import { getActorRunData } from './utils.js';
import { processParamsGetTools } from '../mcp/utils.js';

export function createExpressApp(
host: string,
Expand All @@ -32,7 +33,7 @@ export function createExpressApp(
}
try {
log.info(`Received GET message at: ${Routes.ROOT}`);
const tools = await processParamsGetTools(req.url);
const tools = await processParamsGetTools(req.url, process.env.APIFY_TOKEN as string);
if (tools) {
mcpServer.updateTools(tools);
}
Expand All @@ -53,7 +54,7 @@ export function createExpressApp(
.get(async (req: Request, res: Response) => {
try {
log.info(`Received GET message at: ${Routes.SSE}`);
const tools = await processParamsGetTools(req.url);
const tools = await processParamsGetTools(req.url, process.env.APIFY_TOKEN as string);
if (tools) {
mcpServer.updateTools(tools);
}
Expand Down
7 changes: 0 additions & 7 deletions src/actor/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
export type Input = {
actors: string[] | string;
enableActorAutoLoading?: boolean;
maxActorMemoryBytes?: number;
debugActor?: string;
debugActorInput?: unknown;
};

export interface ActorRunData {
id?: string;
Expand Down
29 changes: 1 addition & 28 deletions src/actor/utils.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,7 @@
import { parse } from 'node:querystring';

import { Actor } from 'apify';
import type { ActorRunData } from './types.js';

import { processInput } from './input.js';
import type { ActorRunData, Input } from './types.js';
import { addTool, getActorsAsTools, removeTool } from '../tools/index.js';
import type { ToolWrap } from '../types.js';

export function parseInputParamsFromUrl(url: string): Input {
const query = url.split('?')[1] || '';
const params = parse(query) as unknown as Input;
return processInput(params);
}

/**
* Process input parameters and get tools
* If URL contains query parameter `actors`, return tools from Actors otherwise return null.
* @param url
*/
export async function processParamsGetTools(url: string) {
const input = parseInputParamsFromUrl(url);
let tools: ToolWrap[] = [];
if (input.actors) {
tools = await getActorsAsTools(input.actors as string[]);
}
if (input.enableActorAutoLoading) {
tools.push(addTool, removeTool);
}
return tools;
}

export function getActorRunData(): ActorRunData | null {
return Actor.isAtHome() ? {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/mcp-apify-client.ts → src/apify-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ApifyClientOptions } from 'apify';
import { ApifyClient as _ApifyClient } from 'apify-client';
import type { AxiosRequestConfig } from 'axios';

import { USER_AGENT_ORIGIN } from '../const.js';
import { USER_AGENT_ORIGIN } from './const.js';

/**
* Adds a User-Agent header to the request config.
Expand All @@ -21,7 +21,7 @@ export class ApifyClient extends _ApifyClient {
constructor(options: ApifyClientOptions) {
super({
...options,
baseUrl: process.env.MCP_APIFY_BASE_URL || undefined,
baseUrl: process.env.APIFY_API_BASE_URL || undefined,
requestInterceptors: [addUserAgent],
});
}
Expand Down
2 changes: 2 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ export const defaults = {
enableActorAutoLoading: false,
maxMemoryMbytes: 4096,
};

export const APIFY_USERNAME = 'apify';
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@

/*
This file provides essential functions and tools for MCP servers, serving as a library.
The ActorsMcpServer should be the only class exported from the package
*/

import { ActorsMcpServer } from './mcp-server.js';
import { ActorsMcpServer } from './mcp/server.js';
export default ActorsMcpServer;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this should be divided into Actor vs mcp-server input

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also agree, we should refactor this

File renamed without changes.
14 changes: 10 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

/**
* Serves as an Actor MCP SSE server entry point.
* This file needs to be named `main.ts` to be recognized by the Apify platform.
Expand All @@ -8,11 +9,12 @@ import type { ActorCallOptions } from 'apify-client';

import log from '@apify/log';

import { processInput } from './actor/input.js';
import { processInput } from './input.js';
import { createExpressApp } from './actor/server.js';
import type { Input } from './actor/types';
import { ActorsMcpServer } from './mcp-server.js';
import { actorDefinitionTool, addTool, removeTool, searchTool, callActorGetDataset } from './tools/index.js';
import type { Input } from './types.js';
import { ActorsMcpServer } from './mcp/server.js';
import { actorDefinitionTool, addTool, removeTool, searchTool, callActorGetDataset, getActorsAsTools } from './tools/index.js';
import { defaults } from './const.js';

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

Expand All @@ -38,6 +40,10 @@ if (STANDBY_MODE) {
if (input.enableActorAutoLoading) {
tools.push(addTool, removeTool);
}
const actors = input.actors ?? defaults.actors;
const actorsToLoad = Array.isArray(actors) ? actors : actors.split(',');
const actorTools = await getActorsAsTools(actorsToLoad, process.env.APIFY_TOKEN as string);
tools.push(...actorTools);
mcpServer.updateTools(tools);
app.listen(PORT, () => {
log.info(`The Actor web server is listening for user requests at ${HOST}`);
Expand Down
76 changes: 76 additions & 0 deletions src/mcp/actors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@

import { ActorDefinition } from "apify-client";
import { ApifyClient } from "../apify-client.js";


export async function isActorMCPServer(actorID: string, apifyToken: string): Promise<boolean> {
const mcpPath = await getActorsMCPServerPath(actorID, apifyToken);
return (mcpPath?.length || 0) > 0;
}

export async function getActorsMCPServerPath(actorID: string, apifyToken: string): Promise<string | undefined> {
const actorDefinition = await getActorDefinition(actorID, apifyToken);
return (actorDefinition as any).webServerMcpPath;
}

export async function getActorsMCPServerURL(actorID: string, apifyToken: string): Promise<string> {
// TODO: get from API instead
const standbyBaseUrl = process.env.HOSTNAME === 'mcp-securitybyobscurity.apify.com' ?
'securitybyobscurity.apify.actor' : 'apify.actor';
const standbyUrl = await getActorStandbyURL(actorID, apifyToken, standbyBaseUrl);
const mcpPath = await getActorsMCPServerPath(actorID, apifyToken);
return `${standbyUrl}${mcpPath}`;
}

/**
* Gets Actor ID from the Actor object.
*
* @param actorID
*/
export async function getRealActorID(actorID: string, apifyToken: string): Promise<string> {
const apifyClient = new ApifyClient({ token: apifyToken });

const actor = apifyClient.actor(actorID);
const info = await actor.get();
if (!info) {
throw new Error(`Actor ${actorID} not found`);
}
return info.id;
}

/**
* Returns standby URL for given Actor ID.
*
* @param actorID
* @param standbyBaseUrl
* @returns
*/
export async function getActorStandbyURL(actorID: string, apifyToken: string, standbyBaseUrl = 'apify.actor'): Promise<string> {
const actorRealID = await getRealActorID(actorID, apifyToken);
return `https://${actorRealID}.${standbyBaseUrl}`;
}

export async function getActorDefinition(actorID: string, apifyToken: string): Promise<ActorDefinition> {
const apifyClient = new ApifyClient({ token: apifyToken
})
const actor = apifyClient.actor(actorID);
const info = await actor.get();
if (!info) {
throw new Error(`Actor ${actorID} not found`);
}
const latestBuildID = info.taggedBuilds?.['latest']?.buildId;
if (!latestBuildID) {
throw new Error(`Actor ${actorID} does not have a latest build`);
}
const build = apifyClient.build(latestBuildID);
const buildInfo = await build.get();
if (!buildInfo) {
throw new Error(`Build ${latestBuildID} not found`);
}
const actorDefinition = buildInfo.actorDefinition;
if (!actorDefinition) {
throw new Error(`Build ${latestBuildID} does not have an actor definition`);
}

return actorDefinition;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we need to add a new function?
We have an existing function for that

/**
 * Get actor input schema by actor name.
 * First, fetch the actor details to get the default build tag and buildId.
 * Then, fetch the build details and return actorName, description, and input schema.
 * @param {string} actorIdOrName - Actor ID or Actor full name.
 * @param {number} limit - Truncate the README to this limit.
 * @returns {Promise<ActorDefinitionWithDesc | null>} - The actor definition with description or null if not found.
 */
export async function getActorDefinition(actorIdOrName: string, limit: number = ACTOR_README_MAX_LENGTH): Promise<ActorDefinitionPruned | null> {
    const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
    const actorClient = client.actor(actorIdOrName);

    try {
        // Fetch actor details
        const actor = await actorClient.get();
        if (!actor) {
            log.error(`Failed to fetch input schema for Actor: ${actorIdOrName}. Actor not found.`);
            return null;
        }

        // fnesveda: The default build is not necessarily tagged, you can specify any build number as default build.
        // There will be a new API endpoint to fetch a default build.
        // For now, we'll use the tagged build, it will work for 90% of Actors. Later, we can update this.
        const tag = actor.defaultRunOptions?.build || '';
        const buildId = actor.taggedBuilds?.[tag]?.buildId || '';

        if (!buildId) {
            log.error(`Failed to fetch input schema for Actor: ${actorIdOrName}. Build ID not found.`);
            return null;
        }
        // Fetch build details and return the input schema
        const buildDetails = await client.build(buildId).get();
        if (buildDetails?.actorDefinition) {
            const actorDefinitions = buildDetails?.actorDefinition as ActorDefinitionWithDesc;
            actorDefinitions.id = actor.id;
            actorDefinitions.readme = truncateActorReadme(actorDefinitions.readme || '', limit);
            actorDefinitions.description = actor.description || '';
            actorDefinitions.actorFullName = `${actor.username}/${actor.name}`;
            actorDefinitions.defaultRunOptions = actor.defaultRunOptions;
            return pruneActorDefinition(actorDefinitions);
        }
        return null;
    } catch (error) {
        const errorMessage = `Failed to fetch input schema for Actor: ${actorIdOrName} with error ${error}.`;
        log.error(errorMessage);
        throw new Error(errorMessage);
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not want to modify this function but it returns only pruned actorDefinition for the Actorized MCP purpose we need to get the webServerMcpPath from top level of actorDefinition

41 changes: 41 additions & 0 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { getMCPServerID } from "./utils.js";

/**
* Creates and connects a ModelContextProtocol client.
*/
export async function createMCPClient(
url: string, token: string
): Promise<Client> {
const transport = new SSEClientTransport(
new URL(url),
{
requestInit: {
headers: {
authorization: `Bearer ${token}`,
},
},
eventSourceInit: {
// The EventSource package augments EventSourceInit with a "fetch" parameter.
// You can use this to set additional headers on the outgoing request.
// Based on this example: https://github.com/modelcontextprotocol/typescript-sdk/issues/118
async fetch(input: Request | URL | string, init?: RequestInit) {
const headers = new Headers(init?.headers || {});
headers.set('authorization', `Bearer ${token}`);
return fetch(input, { ...init, headers });
},
// We have to cast to "any" to use it, since it's non-standard
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
});

const client = new Client({
name: getMCPServerID(url),
version: "1.0.0",
});

await client.connect(transport);

return client;
}
3 changes: 3 additions & 0 deletions src/mcp/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

export const MAX_TOOL_NAME_LENGTH = 64;
export const SERVER_ID_LENGTH = 8
41 changes: 41 additions & 0 deletions src/mcp/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Ajv from "ajv";
import { ActorMCPTool, ToolWrap } from "../types.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { getMCPServerID, getProxyMCPServerToolName } from "./utils.js";

export async function getMCPServerTools(
actorID: string,
client: Client,
// Name of the MCP server
serverUrl: string
): Promise<ToolWrap[]> {
const res = await client.listTools();
const tools = res.tools;


const ajv = new Ajv({ coerceTypes: 'array', strict: false });

const compiledTools: ToolWrap[] = [];
for (const tool of tools) {
const mcpTool: ActorMCPTool = {
actorID,
serverId: getMCPServerID(serverUrl),
serverUrl,
originToolName: tool.name,

name: getProxyMCPServerToolName(serverUrl, tool.name),
description: tool.description || "",
inputSchema: tool.inputSchema,
ajvValidate: ajv.compile(tool.inputSchema)
}

const wrap: ToolWrap = {
type: 'actor-mcp',
tool: mcpTool,
}

compiledTools.push(wrap);
}

return compiledTools;
}
Loading