diff --git a/README.md b/README.md index b32318d..572f818 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,7 @@ For example, it can: You can use the Apify MCP Server in two ways: **HTTPS Endpoint (mcp.apify.com)**: Connect from your MCP client via OAuth or by including the `Authorization: Bearer ` header in your requests. This is the recommended method for most use cases. Because it supports OAuth, you can connect from clients like [Claude.ai](https://claude.ai) or [Visual Studio Code](https://code.visualstudio.com/) using just the URL: `https://mcp.apify.com`. -- `https://mcp.apify.com` (recommended) for streamable transport -- `https://mcp.apify.com/sse` for legacy SSE transport +- `https://mcp.apify.com` streamable transport **Standard Input/Output (stdio)**: Ideal for local integrations and command-line tools like the Claude for Desktop client. - Set the MCP client server command to `npx @apify/actors-mcp-server` and the `APIFY_TOKEN` environment variable to your Apify API token. diff --git a/src/tools/actor.ts b/src/tools/actor.ts index 6eb6c67..5c0fb58 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -405,7 +405,7 @@ EXAMPLES: } const toolsResponse = await client.listTools(); - const toolsInfo = toolsResponse.tools.map((tool) => `**${tool.name}**\n${tool.description || 'No description'}\nInput Schema: ${JSON.stringify(tool.inputSchema, null, 2)}`, + const toolsInfo = toolsResponse.tools.map((tool) => `**${tool.name}**\n${tool.description || 'No description'}\nInput schema: ${JSON.stringify(tool.inputSchema, null, 2)}`, ).join('\n\n'); return buildMCPResponse([`This is an MCP Server Actor with the following tools:\n\n${toolsInfo}\n\nTo call a tool, use step="call" with actor name format: "${baseActorName}:{toolName}"`]); @@ -420,7 +420,7 @@ EXAMPLES: } const content = [ // TODO: update result to say: this is result of info step, you must now call again with step=call and proper input - { type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` }, + { type: 'text', text: `Input schema: \n${JSON.stringify(details.inputSchema, null, 0)}` }, ]; /** * Add Skyfire instructions also in the info step since clients are most likely truncating the long tool description of the call-actor. @@ -499,7 +499,7 @@ EXAMPLES: if (errors && errors.length > 0) { return buildMCPResponse([ `Input validation failed for Actor '${actorName}': ${errors.map((e) => e.message).join(', ')}`, - `Input Schema:\n${JSON.stringify(actor.tool.inputSchema)}`, + `Input schema:\n${JSON.stringify(actor.tool.inputSchema)}`, ]); } } diff --git a/src/tools/fetch-actor-details.ts b/src/tools/fetch-actor-details.ts index 1fb22f3..90d7178 100644 --- a/src/tools/fetch-actor-details.ts +++ b/src/tools/fetch-actor-details.ts @@ -40,13 +40,23 @@ USAGE EXAMPLES: content: [{ type: 'text', text: `Actor information for '${parsed.actor}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }], }; } - return { - content: [ - { type: 'text', text: `**Actor card**:\n${details.actorCard}` }, - { type: 'text', text: `**README:**\n${details.readme}` }, - { type: 'text', text: `**Input Schema:**\n${JSON.stringify(details.inputSchema, null, 0)}` }, - ], - }; + + const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`; + // Add link to README title + details.readme = details.readme.replace(/^# /, `# [README](${actorUrl}/readme): `); + + const content = [ + { type: 'text', text: `# Actor information\n${details.actorCard}` }, + { type: 'text', text: `${details.readme}` }, + ]; + + // Include input schema if it has properties + if (details.inputSchema.properties || Object.keys(details.inputSchema.properties).length !== 0) { + content.push({ type: 'text', text: `# [Input schema](${actorUrl}/input)\n\`\`\`json\n${JSON.stringify(details.inputSchema, null, 0)}\n\`\`\`` }); + } + // Return the actor card, README, and input schema (if it has non-empty properties) as separate text blocks + // This allows better formatting in the final output + return { content }; }, } as InternalTool, }; diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 1ad5d6a..4c7f9b1 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -5,7 +5,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js'; import type { ActorPricingModel, ExtendedActorStoreList, HelperTool, ToolEntry } from '../types.js'; -import { formatActorsListToActorCard } from '../utils/actor-card.js'; +import { formatActorToActorCard } from '../utils/actor-card.js'; import { ajv } from '../utils/ajv.js'; export async function searchActorsByKeywords( @@ -99,14 +99,24 @@ USAGE EXAMPLES: parsed.offset, ); actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit); - const actorCards = formatActorsListToActorCard(actors); + const actorCards = actors.length === 0 ? [] : actors.map(formatActorToActorCard); + + const actorsText = actorCards.length + ? actorCards.join('\n\n') + : 'No Actors were found for the given search query. Please try different keywords or simplify your query.'; + return { content: [ { type: 'text', - text: `**Search query:** ${parsed.search}\n\n` - + `**Number of Actors found:** ${actorCards.length}\n\n` - + `**Actor cards:**\n${actorCards.join('\n\n')}`, + text: ` +# Search results: +- **Search query:** ${parsed.search} +- **Number of Actors found:** ${actorCards.length} + +# Actors: + +${actorsText}`, }, ], }; diff --git a/src/utils/actor-card.ts b/src/utils/actor-card.ts index 2a69997..d82f393 100644 --- a/src/utils/actor-card.ts +++ b/src/utils/actor-card.ts @@ -20,7 +20,7 @@ function formatCategories(categories?: string[]): string[] { } /** - * Formats Actor details into an Actor card (Actor markdown representation). + * Formats Actor details into an Actor card (Actor information in markdown). * @param actor - Actor information from the API * @returns Formatted actor card */ @@ -42,14 +42,16 @@ export function formatActorToActorCard( } const actorFullName = `${actor.username}/${actor.name}`; + const actorUrl = `${APIFY_STORE_URL}/${actorFullName}`; // Build the markdown lines const markdownLines = [ - `# [${actor.title}](${APIFY_STORE_URL}/${actorFullName}) (${actorFullName})`, - `**Developed by:** ${actor.username} ${actor.username === 'apify' ? '(Apify)' : '(community)'}`, - `**Description:** ${actor.description || 'No description provided.'}`, - `**Categories:** ${formattedCategories.length ? formattedCategories.join(', ') : 'Uncategorized'}`, - `**Pricing:** ${pricingInfo}`, + `## [${actor.title}](${actorUrl}) (\`${actorFullName}\`)`, + `- **URL:** ${actorUrl}`, + `- **Developed by:** [${actor.username}](${APIFY_STORE_URL}/${actor.username}) ${actor.username === 'apify' ? '(Apify)' : '(community)'}`, + `- **Description:** ${actor.description || 'No description provided.'}`, + `- **Categories:** ${formattedCategories.length ? formattedCategories.join(', ') : 'Uncategorized'}`, + `- **[Pricing](${actorUrl}/pricing):** ${pricingInfo}`, ]; // Add stats - handle different stat structures @@ -80,18 +82,18 @@ export function formatActorToActorCard( } if (statsParts.length > 0) { - markdownLines.push(`**Stats:** ${statsParts.join(', ')}`); + markdownLines.push(`- **Stats:** ${statsParts.join(', ')}`); } } // Add rating if available (ActorStoreList only) if ('actorReviewRating' in actor && actor.actorReviewRating) { - markdownLines.push(`**Rating:** ${actor.actorReviewRating.toFixed(2)} out of 5`); + markdownLines.push(`- **Rating:** ${actor.actorReviewRating.toFixed(2)} out of 5`); } // Add modification date if available if ('modifiedAt' in actor) { - markdownLines.push(`**Last modified:** ${actor.modifiedAt.toISOString()}`); + markdownLines.push(`- **Last modified:** ${actor.modifiedAt.toISOString()}`); } // Add deprecation warning if applicable @@ -100,18 +102,3 @@ export function formatActorToActorCard( } return markdownLines.join('\n'); } - -/** - * Formats a list of Actors into Actor cards - * @param actors - Array of Actor information - * @returns Formatted markdown string - */ -export function formatActorsListToActorCard(actors: (Actor | ExtendedActorStoreList)[]): string[] { - if (actors.length === 0) { - return []; - } - return actors.map((actor) => { - const card = formatActorToActorCard(actor); - return `- ${card}`; - }); -} diff --git a/src/utils/pricing-info.ts b/src/utils/pricing-info.ts index cc9a00e..3ff33ef 100644 --- a/src/utils/pricing-info.ts +++ b/src/utils/pricing-info.ts @@ -41,11 +41,24 @@ function convertMinutesToGreatestUnit(minutes: number): { value: number; unit: s return { value: Math.floor(minutes / (60 * 24)), unit: 'days' }; } +/** + * Formats the pay-per-event pricing information into a human-readable string. + * + * Example: + * This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for the following events: + * - Event title: Event description (Flat price: $X per event) + * - MCP server startup: Initial fee for starting the Kiwi MCP Server Actor (Flat price: $0.1 per event) + * - Flight search: Fee for searching flights using the Kiwi.com flight search engine (Flat price: $0.001 per event) + * + * For tiered pricing, the output is more complicated and the question is whether we want to simplify it in the future. + * @param pricingPerEvent + */ + function payPerEventPricingToString(pricingPerEvent: ExtendedPricingInfo['pricingPerEvent']): string { - if (!pricingPerEvent || !pricingPerEvent.actorChargeEvents) return 'No event pricing information available.'; + if (!pricingPerEvent || !pricingPerEvent.actorChargeEvents) return 'Pricing information for events is not available.'; const eventStrings: string[] = []; for (const event of Object.values(pricingPerEvent.actorChargeEvents)) { - let eventStr = `- ${event.eventTitle}: ${event.eventDescription} `; + let eventStr = `\t- **${event.eventTitle}**: ${event.eventDescription} `; if (typeof event.eventPriceUsd === 'number') { eventStr += `(Flat price: $${event.eventPriceUsd} per event)`; } else if (event.eventTieredPricingUsd) { @@ -58,14 +71,14 @@ function payPerEventPricingToString(pricingPerEvent: ExtendedPricingInfo['pricin } eventStrings.push(eventStr); } - return `This Actor charges per event as follows:\n${eventStrings.join('\n')}`; + return `This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for the following events:\n${eventStrings.join('\n')}`; } export function pricingInfoToString(pricingInfo: ExtendedPricingInfo | null): string { // If there is no pricing infos entries the Actor is free to use // based on https://github.com/apify/apify-core/blob/058044945f242387dde2422b8f1bef395110a1bf/src/packages/actor/src/paid_actors/paid_actors_common.ts#L691 if (pricingInfo === null || pricingInfo.pricingModel === ACTOR_PRICING_MODEL.FREE) { - return 'This Actor is free to use; the user only pays for the computing resources consumed by the Actor.'; + return 'This Actor is free to use. You are only charged for Apify platform usage.'; } if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.PRICE_PER_DATASET_ITEM) { const customUnitName = pricingInfo.unitName !== 'result' ? pricingInfo.unitName : ''; @@ -85,12 +98,12 @@ export function pricingInfoToString(pricingInfo: ExtendedPricingInfo | null): st const tiers = Object.entries(pricingInfo.tieredPricing) .map(([tier, obj]) => `${tier}: $${obj.tieredPricePerUnitUsd} per month`) .join(', '); - return `This Actor is rental and thus has tiered pricing per month: ${tiers}, with a trial period of ${value} ${unit}.`; + return `This Actor is rental and has tiered pricing per month: ${tiers}, with a trial period of ${value} ${unit}.`; } - return `This Actor is rental and thus has a flat price of ${pricingInfo.pricePerUnitUsd} USD per month, with a trial period of ${value} ${unit}.`; + return `This Actor is rental and has a flat price of ${pricingInfo.pricePerUnitUsd} USD per month, with a trial period of ${value} ${unit}.`; } if (pricingInfo.pricingModel === ACTOR_PRICING_MODEL.PAY_PER_EVENT) { return payPerEventPricingToString(pricingInfo.pricingPerEvent); } - return 'unknown'; + return 'Pricing information is not available.'; }