Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,13 @@ Here are some special MCP operations and how the Apify MCP Server supports them:

For example, to enable all tools, use `npx @apify/actors-mcp-server --tools docs,runs,storage,preview` or `https://mcp.apify.com/?tools=docs,runs,storage,preview`.

### Prompt & Resources
### Prompts

The server does not yet provide any resources or prompts.
The server provides a set of predefined example prompts to help you get started interacting with Apify through MCP. For example, there is a `GetLatestNewsOnTopic` prompt that allows you to easily retrieve the latest news on a specific topic using the [RAG Web Browser](https://apify.com/apify/rag-web-browser) Actor.

### Resources

The server does not yet provide any resources.

### Debugging the NPM package

Expand Down
49 changes: 49 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
CallToolRequestSchema,
CallToolResultSchema,
ErrorCode,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListToolsRequestSchema,
McpError,
ServerNotificationSchema,
Expand All @@ -23,6 +25,7 @@ import {
SERVER_NAME,
SERVER_VERSION,
} from '../const.js';
import { prompts } from '../prompts/index.js';
import { addRemoveTools, callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js';
import { actorNameToToolName, decodeDotPropertyNames } from '../tools/utils.js';
import type { ActorMcpTool, ActorTool, HelperTool, ToolEntry } from '../types.js';
Expand Down Expand Up @@ -61,13 +64,15 @@ export class ActorsMcpServer {
{
capabilities: {
tools: { listChanged: true },
prompts: { },
logging: {},
},
},
);
this.tools = new Map();
this.setupErrorHandling(setupSigintHandler);
this.setupToolHandlers();
this.setupPromptHandlers();

// Add default tools
this.upsertTools(defaultTools);
Expand Down Expand Up @@ -334,6 +339,50 @@ export class ActorsMcpServer {
}
}

/**
* Sets up MCP request handlers for prompts.
*/
private setupPromptHandlers(): void {
/**
* Handles the prompts/list request.
*/
this.server.setRequestHandler(ListPromptsRequestSchema, () => {
return { prompts };
});

/**
* Handles the prompts/get request.
*/
this.server.setRequestHandler(GetPromptRequestSchema, (request) => {
const { name, arguments: args } = request.params;
const prompt = prompts.find((p) => p.name === name);
if (!prompt) {
throw new McpError(
ErrorCode.InvalidParams,
`Prompt ${name} not found. Available prompts: ${prompts.map((p) => p.name).join(', ')}`,
);
}
if (!prompt.ajvValidate(args)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for prompt ${name}: args: ${JSON.stringify(args)} error: ${JSON.stringify(prompt.ajvValidate.errors)}`,
);
}
return {
description: prompt.description,
messages: [
{
role: 'user',
content: {
type: 'text',
text: prompt.render(args || {}),
},
},
],
};
});
}

private setupToolHandlers(): void {
/**
* Handles the request to list tools.
Expand Down
9 changes: 9 additions & 0 deletions src/prompts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { PromptBase } from '../types.js';
import { latestNewsOnTopicPrompt } from './latest-news-on-topic.js';

/**
* List of all enabled prompts.
*/
export const prompts: PromptBase[] = [
latestNewsOnTopicPrompt,
];
52 changes: 52 additions & 0 deletions src/prompts/latest-news-on-topic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js';

import { fixedAjvCompile } from '../tools/utils.js';
import type { PromptBase } from '../types.js';
import { ajv } from '../utils/ajv.js';

/**
* Prompt MCP arguments list.
*/
const args: PromptArgument[] = [
{
name: 'topic',
description: 'The topic to retrieve the latest news on.',
required: true,
},
{
name: 'timespan',
description: 'The timespan for which to retrieve news articles. Defaults to "7 days". For example "1 day", "3 days", "7 days", "1 month", etc.',
required: false,
},
];

/**
* Prompt AJV arguments schema for validation.
*/
const argsSchema = fixedAjvCompile(ajv, {
type: 'object',
properties: {
...Object.fromEntries(args.map((arg) => [arg.name, {
type: 'string',
description: arg.description,
default: arg.default,
examples: arg.examples,
}])),
},
required: [...args.filter((arg) => arg.required).map((arg) => arg.name)],
});

/**
* Actual prompt definition.
*/
export const latestNewsOnTopicPrompt: PromptBase = {
name: 'GetLatestNewsOnTopic',
description: 'This prompt retrieves the latest news articles on a selected topic.',
arguments: args,
ajvValidate: argsSchema,
render: (data) => {
const currentDateUtc = new Date().toISOString().split('T')[0];
const timespan = data.timespan && data.timespan.trim() !== '' ? data.timespan : '7 days';
return `I want you to use the RAG web browser to search the web for the latest news on the "${data.topic}" topic. Retrieve news from the last ${timespan}. The RAG web browser accepts a query parameter that supports all Google input, including filters and flags—be sure to use them to accomplish my goal. Today is ${currentDateUtc} UTC.`;
},
};
13 changes: 12 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { Notification, Request } from '@modelcontextprotocol/sdk/types.js';
import type { Notification, Prompt, Request } from '@modelcontextprotocol/sdk/types.js';
import type { ValidateFunction } from 'ajv';
import type { ActorDefaultRunOptions, ActorDefinition, ActorStoreList, PricingInfo } from 'apify-client';

Expand Down Expand Up @@ -275,3 +275,14 @@ export interface ApifyDocsSearchResult {
/** Piece of content that matches the search query from Algolia */
content: string;
}

export type PromptBase = Prompt & {
/**
* AJV validation function for the prompt arguments.
*/
ajvValidate: ValidateFunction;
/**
* Function to render the prompt with given arguments
*/
render: (args: Record<string, string>) => string;
};
29 changes: 27 additions & 2 deletions tests/integration/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/typ
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import { defaults, HelperTools } from '../../src/const.js';
import { latestNewsOnTopicPrompt } from '../../src/prompts/latest-news-on-topic.js';
import { addRemoveTools, defaultTools, toolCategories, toolCategoriesEnabledByDefault } from '../../src/tools/index.js';
import type { ISearchActorsResult } from '../../src/tools/store_collection.js';
import { actorNameToToolName } from '../../src/tools/utils.js';
Expand Down Expand Up @@ -436,8 +437,7 @@ export function createIntegrationTestsSuite(
// Handle case where tools are enabled by default
const selectedCategoriesInDefault = categories.filter((key) => toolCategoriesEnabledByDefault.includes(key));
const numberOfToolsFromCategoriesInDefault = selectedCategoriesInDefault
.map((key) => toolCategories[key])
.flat().length;
.flatMap((key) => toolCategories[key]).length;

const numberOfToolsExpected = defaultTools.length + defaults.actors.length + addRemoveTools.length
// Tools from tool categories minus the ones already in default tools
Expand All @@ -450,6 +450,31 @@ export function createIntegrationTestsSuite(
await client.close();
});

it('should list all prompts', async () => {
const client = await createClientFn();
const prompts = await client.listPrompts();
expect(prompts.prompts.length).toBeGreaterThan(0);
await client.close();
});

it('should be able to get prompt by name', async () => {
const client = await createClientFn();

const topic = 'apify';
const prompt = await client.getPrompt({
name: latestNewsOnTopicPrompt.name,
arguments: {
topic,
},
});

const message = prompt.messages[0];
expect(message).toBeDefined();
expect(message.content.text).toContain(topic);

await client.close();
});

// Session termination is only possible for streamable HTTP transport.
it.runIf(options.transport === 'streamable-http')('should successfully terminate streamable session', async () => {
const client = await createClientFn();
Expand Down