Skip to content

Commit 681c466

Browse files
authored
feat: tool state handler (#116)
* add tools changed handler to get notifications and be able to store tools state (in redis) * add tools changed handler * comment * do not notify tools changed handler by default, improve docs * rename param * add search test
1 parent 7f60950 commit 681c466

File tree

8 files changed

+413
-11
lines changed

8 files changed

+413
-11
lines changed

src/mcp/server.ts

Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
SERVER_NAME,
1818
SERVER_VERSION,
1919
} from '../const.js';
20+
import { internalToolsMap } from '../toolmap.js';
2021
import { helpTool } from '../tools/helpers.js';
2122
import {
2223
actorDefinitionTool,
@@ -37,13 +38,16 @@ type ActorsMcpServerOptions = {
3738
enableDefaultActors?: boolean;
3839
};
3940

41+
type ToolsChangedHandler = (toolNames: string[]) => void;
42+
4043
/**
4144
* Create Apify MCP server
4245
*/
4346
export class ActorsMcpServer {
4447
public readonly server: Server;
4548
public readonly tools: Map<string, ToolWrap>;
4649
private readonly options: ActorsMcpServerOptions;
50+
private toolsChangedHandler: ToolsChangedHandler | undefined;
4751

4852
constructor(options: ActorsMcpServerOptions = {}, setupSIGINTHandler = true) {
4953
this.options = {
@@ -80,13 +84,128 @@ export class ActorsMcpServer {
8084
});
8185
}
8286

87+
/**
88+
* Returns a list of Actor IDs that are registered as MCP servers.
89+
* @returns {string[]} - An array of Actor MCP server Actor IDs (e.g., 'apify/actors-mcp-server').
90+
*/
91+
public getToolMCPServerActors(): string[] {
92+
const mcpServerActors: Set<string> = new Set();
93+
for (const tool of this.tools.values()) {
94+
if (tool.type === 'actor-mcp') {
95+
mcpServerActors.add((tool.tool as ActorMCPTool).actorID);
96+
}
97+
}
98+
99+
return Array.from(mcpServerActors);
100+
}
101+
102+
/**
103+
* Register handler to get notified when tools change.
104+
* The handler receives an array of tool names that the server has after the change.
105+
* This is primarily used to store the tools in shared state (e.g., Redis) for recovery
106+
* when the server loses local state.
107+
* @throws {Error} - If a handler is already registered.
108+
* @param handler - The handler function to be called when tools change.
109+
*/
110+
public registerToolsChangedHandler(handler: (toolNames: string[]) => void) {
111+
if (this.toolsChangedHandler) {
112+
throw new Error('Tools changed handler is already registered.');
113+
}
114+
this.toolsChangedHandler = handler;
115+
}
116+
117+
/**
118+
* Unregister the handler for tools changed event.
119+
* @throws {Error} - If no handler is currently registered.
120+
*/
121+
public unregisterToolsChangedHandler() {
122+
if (!this.toolsChangedHandler) {
123+
throw new Error('Tools changed handler is not registered.');
124+
}
125+
this.toolsChangedHandler = undefined;
126+
}
127+
128+
/**
129+
* Loads missing tools from a provided list of tool names.
130+
* Skips tools that are already loaded and loads only the missing ones.
131+
* @param tools - Array of tool names to ensure are loaded
132+
* @param apifyToken - Apify API token for authentication
133+
*/
134+
public async loadToolsFromToolsList(tools: string[], apifyToken: string) {
135+
const loadedTools = this.getLoadedActorToolsList();
136+
const actorsToLoad: string[] = [];
137+
138+
for (const tool of tools) {
139+
// Skip if the tool is already loaded
140+
if (loadedTools.includes(tool)) {
141+
continue;
142+
}
143+
144+
// Load internal tool
145+
if (internalToolsMap.has(tool)) {
146+
const toolWrap = internalToolsMap.get(tool) as ToolWrap;
147+
this.tools.set(tool, toolWrap);
148+
log.info(`Added internal tool: ${tool}`);
149+
// Handler Actor tool
150+
} else {
151+
actorsToLoad.push(tool);
152+
}
153+
}
154+
155+
if (actorsToLoad.length > 0) {
156+
const actorTools = await getActorsAsTools(actorsToLoad, apifyToken);
157+
if (actorTools.length > 0) {
158+
this.updateTools(actorTools);
159+
}
160+
log.info(`Loaded tools: ${actorTools.map((t) => t.tool.name).join(', ')}`);
161+
}
162+
}
163+
164+
/**
165+
* Returns the list of all currently loaded Actor tool IDs.
166+
* @returns {string[]} - Array of loaded Actor tool IDs (e.g., 'apify/rag-web-browser')
167+
*/
168+
public getLoadedActorToolsList(): string[] {
169+
// Get the list of tool names
170+
const tools: string[] = [];
171+
for (const tool of this.tools.values()) {
172+
if (tool.type === 'actor') {
173+
tools.push((tool.tool as ActorTool).actorFullName);
174+
// Skip Actorized MCP servers since there may be multiple tools from the same Actor MCP server
175+
// so we skip and then get unique list of Actor MCP servers separately
176+
} else if (tool.type === 'actor-mcp') {
177+
continue;
178+
} else {
179+
tools.push(tool.tool.name);
180+
}
181+
}
182+
// Add unique list Actorized MCP servers original Actor IDs - for example: apify/actors-mcp-server
183+
tools.push(...this.getToolMCPServerActors());
184+
185+
return tools;
186+
}
187+
188+
private notifyToolsChangedHandler() {
189+
// If no handler is registered, do nothing
190+
if (!this.toolsChangedHandler) return;
191+
192+
// Get the list of tool names
193+
const tools: string[] = this.getLoadedActorToolsList();
194+
195+
this.toolsChangedHandler(tools);
196+
}
197+
83198
/**
84199
* Resets the server to the default state.
85200
* This method clears all tools and loads the default tools.
86201
* Used primarily for testing purposes.
87202
*/
88203
public async reset(): Promise<void> {
89204
this.tools.clear();
205+
// Unregister the tools changed handler
206+
if (this.toolsChangedHandler) {
207+
this.unregisterToolsChangedHandler();
208+
}
90209
this.updateTools([searchTool, actorDefinitionTool, helpTool]);
91210
if (this.options.enableAddingActors) {
92211
this.loadToolsToAddActors();
@@ -128,30 +247,57 @@ export class ActorsMcpServer {
128247
const tools = await processParamsGetTools(url, apifyToken);
129248
if (tools.length > 0) {
130249
log.info('Loading tools from query parameters...');
131-
this.updateTools(tools);
250+
this.updateTools(tools, false);
132251
}
133252
}
134253

135254
/**
136255
* Add Actors to server dynamically
137256
*/
138257
public loadToolsToAddActors() {
139-
this.updateTools([addTool, removeTool]);
258+
this.updateTools([addTool, removeTool], false);
140259
}
141260

142261
/**
143262
* Upsert new tools.
144-
* @param tools - Array of tool wrappers.
145-
* @returns Array of tool wrappers.
263+
* @param tools - Array of tool wrappers to add or update
264+
* @param shouldNotifyToolsChangedHandler - Whether to notify the tools changed handler
265+
* @returns Array of added/updated tool wrappers
146266
*/
147-
public updateTools(tools: ToolWrap[]) {
267+
public updateTools(tools: ToolWrap[], shouldNotifyToolsChangedHandler = false) {
148268
for (const wrap of tools) {
149269
this.tools.set(wrap.tool.name, wrap);
150270
log.info(`Added/updated tool: ${wrap.tool.name}`);
151271
}
272+
if (shouldNotifyToolsChangedHandler) this.notifyToolsChangedHandler();
152273
return tools;
153274
}
154275

276+
/**
277+
* Delete tools by name.
278+
* Notifies the tools changed handler if any tools were deleted.
279+
* @param toolNames - Array of tool names to delete
280+
* @returns Array of tool names that were successfully deleted
281+
*/
282+
public deleteTools(toolNames: string[]): string[] {
283+
const notFoundTools: string[] = [];
284+
// Delete the tools
285+
for (const toolName of toolNames) {
286+
if (this.tools.has(toolName)) {
287+
this.tools.delete(toolName);
288+
log.info(`Deleted tool: ${toolName}`);
289+
} else {
290+
notFoundTools.push(toolName);
291+
}
292+
}
293+
294+
if (toolNames.length > notFoundTools.length) {
295+
this.notifyToolsChangedHandler();
296+
}
297+
// Return the list of tools that were removed
298+
return toolNames.filter((toolName) => !notFoundTools.includes(toolName));
299+
}
300+
155301
/**
156302
* Returns an array of tool names.
157303
* @returns {string[]} - An array of tool names.

src/toolmap.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// This module was created to prevent circular import dependency issues
2+
import { HelperTools } from './const.js';
3+
import { helpTool } from './tools/helpers.js';
4+
import { actorDefinitionTool, addTool, removeTool } from './tools/index.js';
5+
import { searchActorTool } from './tools/store_collection.js';
6+
import type { ToolWrap } from './types.js';
7+
8+
/**
9+
* Map of internal tools indexed by their name.
10+
* Created to prevent circular import dependencies between modules.
11+
*/
12+
export const internalToolsMap: Map<string, ToolWrap> = new Map([
13+
[HelperTools.SEARCH_ACTORS.toString(), searchActorTool],
14+
[HelperTools.ADD_ACTOR.toString(), addTool],
15+
[HelperTools.REMOVE_ACTOR.toString(), removeTool],
16+
[HelperTools.GET_ACTOR_DETAILS.toString(), actorDefinitionTool],
17+
[HelperTools.HELP_TOOL.toString(), helpTool],
18+
]);

src/tools/helpers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const addTool: ToolWrap = {
8080
const { apifyMcpServer, mcpServer, apifyToken, args } = toolArgs;
8181
const parsed = AddToolArgsSchema.parse(args);
8282
const tools = await getActorsAsTools([parsed.actorName], apifyToken);
83-
const toolsAdded = apifyMcpServer.updateTools(tools);
83+
const toolsAdded = apifyMcpServer.updateTools(tools, true);
8484
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
8585

8686
return {
@@ -110,9 +110,9 @@ export const removeTool: ToolWrap = {
110110
const { apifyMcpServer, mcpServer, args } = toolArgs;
111111

112112
const parsed = RemoveToolArgsSchema.parse(args);
113-
apifyMcpServer.tools.delete(parsed.toolName);
113+
const removedTools = apifyMcpServer.deleteTools([parsed.toolName]);
114114
await mcpServer.notification({ method: 'notifications/tools/list_changed' });
115-
return { content: [{ type: 'text', text: `Tool ${parsed.toolName} was removed` }] };
115+
return { content: [{ type: 'text', text: `Tools removed: ${removedTools.join(', ')}` }] };
116116
},
117117
} as InternalTool,
118118
};

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export interface HelperTool extends ToolBase {
107107
export interface ActorMCPTool extends ToolBase {
108108
// Origin MCP server tool name, is needed for the tool call
109109
originToolName: string;
110-
// ID of the Actorized MCP server
110+
// ID of the Actorized MCP server - for example apify/actors-mcp-server
111111
actorID: string;
112112
/**
113113
* ID of the Actorized MCP server the tool is associated with.

tests/helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
22
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
33
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
44
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5+
import { expect } from 'vitest';
6+
7+
import { HelperTools } from '../src/const.js';
58

69
export interface MCPClientOptions {
710
actors?: string[];
@@ -109,3 +112,29 @@ export async function createMCPStdioClient(
109112

110113
return client;
111114
}
115+
116+
/**
117+
* Adds an Actor as a tool using the ADD_ACTOR helper tool.
118+
* @param client - MCP client instance
119+
* @param actorName - Name of the Actor to add
120+
*/
121+
export async function addActor(client: Client, actorName: string): Promise<void> {
122+
await client.callTool({
123+
name: HelperTools.ADD_ACTOR,
124+
arguments: {
125+
actorName,
126+
},
127+
});
128+
}
129+
130+
/**
131+
* Asserts that two arrays contain the same elements, regardless of order.
132+
* @param array - The array to test
133+
* @param values - The expected values
134+
*/
135+
export function expectArrayWeakEquals(array: unknown[], values: unknown[]): void {
136+
expect(array.length).toBe(values.length);
137+
for (const value of values) {
138+
expect(array).toContainEqual(value);
139+
}
140+
}

tests/integration/actor.server-sse.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const mcpUrl = `${httpServerHost}/sse`;
1818

1919
createIntegrationTestsSuite({
2020
suiteName: 'Actors MCP Server SSE',
21+
concurrent: false,
22+
getActorsMCPServer: () => mcpServer,
2123
createClientFn: async (options) => await createMCPSSEClient(mcpUrl, options),
2224
beforeAllFn: async () => {
2325
mcpServer = new ActorsMcpServer({

tests/integration/actor.server-streamable.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const mcpUrl = `${httpServerHost}/mcp`;
1818

1919
createIntegrationTestsSuite({
2020
suiteName: 'Actors MCP Server Streamable HTTP',
21+
concurrent: false,
22+
getActorsMCPServer: () => mcpServer,
2123
createClientFn: async (options) => await createMCPStreamableClient(mcpUrl, options),
2224
beforeAllFn: async () => {
2325
mcpServer = new ActorsMcpServer({

0 commit comments

Comments
 (0)