diff --git a/README.md b/README.md index 0716f0dd..e08d5512 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cc022826..fb9daad6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -9,6 +9,8 @@ import { CallToolRequestSchema, CallToolResultSchema, ErrorCode, + GetPromptRequestSchema, + ListPromptsRequestSchema, ListToolsRequestSchema, McpError, ServerNotificationSchema, @@ -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'; @@ -61,6 +64,7 @@ export class ActorsMcpServer { { capabilities: { tools: { listChanged: true }, + prompts: { }, logging: {}, }, }, @@ -68,6 +72,7 @@ export class ActorsMcpServer { this.tools = new Map(); this.setupErrorHandling(setupSigintHandler); this.setupToolHandlers(); + this.setupPromptHandlers(); // Add default tools this.upsertTools(defaultTools); @@ -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. diff --git a/src/prompts/index.ts b/src/prompts/index.ts new file mode 100644 index 00000000..5352abce --- /dev/null +++ b/src/prompts/index.ts @@ -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, +]; diff --git a/src/prompts/latest-news-on-topic.ts b/src/prompts/latest-news-on-topic.ts new file mode 100644 index 00000000..aeea3f70 --- /dev/null +++ b/src/prompts/latest-news-on-topic.ts @@ -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.`; + }, +}; diff --git a/src/types.ts b/src/types.ts index 7a68cb17..d0114728 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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'; @@ -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; +}; diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index b5844875..d03c80d2 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -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'; @@ -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 @@ -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();