-
Notifications
You must be signed in to change notification settings - Fork 78
feat: Actorized MCP servers proxy #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 23 commits
3f27a38
5dd679c
9a0846b
b8b3ce3
3543a5e
6c488dd
8712ffd
b91a305
c273cfb
e005b65
0bf2e0c
f14025d
caaebb5
c2caf6c
364c0c4
69c4327
3c92717
1c3dc7a
2f6dec6
46ef186
c39b5ef
7703eba
29f3561
a8f598e
3292609
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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; | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why we need to add a new function?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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; | ||
| } |
| 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 |
| 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; | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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