Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
4 changes: 2 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import { createProgressTracker } from '../utils/progress.js';
import { getServerInstructions } from '../utils/server-instructions/index.js';
import { classifyFailureCategory, extractAjvErrorDetails, extractToolTelemetry, getToolStatusFromError } from '../utils/tool_status.js';
import { buildActorFields, extractActorId, extractActorName, getToolFullName, getToolPublicFieldOnly } from '../utils/tools.js';
import { getUserIdFromTokenCached } from '../utils/userid_cache.js';
import { getUserInfoCached } from '../utils/userid_cache.js';
import { getPackageVersion } from '../utils/version.js';
import { connectMCPClient } from './client.js';
import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, LOG_LEVEL_MAP } from './const.js';
Expand Down Expand Up @@ -1364,7 +1364,7 @@ export class ActorsMcpServer {
let userId: string | null = null;
if (apifyToken) {
const apifyClient = new ApifyClient({ token: apifyToken });
userId = await getUserIdFromTokenCached(apifyToken, apifyClient);
({ userId } = await getUserInfoCached(apifyToken, apifyClient));
log.debug('Telemetry: fetched userId', { userId, mcpSessionId });
}
const capabilities = this.options.initializeRequestData?.params?.capabilities;
Expand Down
11 changes: 10 additions & 1 deletion src/tools/core/fetch_actor_details_common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../../utils/actor_details.js';
import { compileSchema } from '../../utils/ajv.js';
import { buildMCPResponse } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import { actorDetailsOutputSchema } from '../structured_output_schemas.js';
import { fixActorNameInputAndLog } from './actor_tools_factory.js';

Expand Down Expand Up @@ -230,7 +231,15 @@ export async function buildFetchActorDetailsResult(
const apifyClient = new ApifyClient({ token: apifyToken });

const resolvedOutput = resolveOutputOptions(parsed.output);
const details = await fetchActorDetails(apifyClient, actorName, buildCardOptions(resolvedOutput));
// Skip the /users/me round-trip when pricing isn't rendered (e.g. inputSchema-only
// or mcpTools-only requests). In that case `userTier` is only used to fill the
// placeholder `{ model: 'FREE', userTier }` in the structured card, where it's never
// read, so defaulting to 'FREE' is safe and saves a request.
const userPlanTier = resolvedOutput.pricing
? (await getUserInfoCached(apifyToken, apifyClient)).userPlanTier
: 'FREE';
const cardOptions = { ...buildCardOptions(resolvedOutput), userTier: userPlanTier };
const details = await fetchActorDetails(apifyClient, actorName, cardOptions);
if (!details) {
return buildActorNotFoundResponse(actorName);
}
Expand Down
9 changes: 6 additions & 3 deletions src/tools/core/search_actors_common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { z } from 'zod';
import { HelperTools } from '../../const.js';
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
import type { ActorStoreList, HelperTool, StructuredActorCard, ToolInputSchema } from '../../types.js';
import { formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js';
import { DEFAULT_CARD_OPTIONS, formatActorToActorCard, formatActorToStructuredCard } from '../../utils/actor_card.js';
import { compileSchema } from '../../utils/ajv.js';
import { buildMCPResponse } from '../../utils/mcp.js';
import type { PricingTier } from '../../utils/pricing_info.js';
import { actorSearchOutputSchema } from '../structured_output_schemas.js';

/**
Expand Down Expand Up @@ -112,10 +113,12 @@ export type SearchActorsResult = {

export function buildSearchActorsResult(
actors: ActorStoreList[],
userTier: PricingTier,
): SearchActorsResult {
const options = { ...DEFAULT_CARD_OPTIONS, userTier, simplifyPricingForUserTier: true };
return {
actorCardText: actors.map((actor) => formatActorToActorCard(actor)).join('\n\n'),
actorCardStructured: actors.map((actor) => formatActorToStructuredCard(actor)),
actorCardText: actors.map((actor) => formatActorToActorCard(actor, options)).join('\n\n'),
actorCardStructured: actors.map((actor) => formatActorToStructuredCard(actor, options)),
};
}

Expand Down
26 changes: 16 additions & 10 deletions src/tools/default/search_actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HelperTools } from '../../const.js';
import type { InternalToolArgs, ToolEntry } from '../../types.js';
import { searchAndFilterActors } from '../../utils/actor_search.js';
import { buildMCPResponse } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import {
buildSearchActorsEmptyResponse,
buildSearchActorsResult,
Expand All @@ -18,22 +19,27 @@ import {
export const defaultSearchActors: ToolEntry = Object.freeze({
...searchActorsMetadata,
call: async (toolArgs: InternalToolArgs) => {
const { args, apifyToken, userRentedActorIds, apifyMcpServer } = toolArgs;
const { args, apifyToken, apifyClient, userRentedActorIds, apifyMcpServer } = toolArgs;
const parsed = searchActorsArgsSchema.parse(args);
const actors = await searchAndFilterActors({
keywords: parsed.keywords,
apifyToken,
limit: parsed.limit,
offset: parsed.offset,
paymentProvider: apifyMcpServer.options.paymentProvider,
userRentedActorIds,
});
// Actor search and user-info fetch are independent; run in parallel to avoid a
// sequential round-trip on cache miss.
const [actors, { userPlanTier }] = await Promise.all([
searchAndFilterActors({
keywords: parsed.keywords,
apifyToken,
limit: parsed.limit,
offset: parsed.offset,
paymentProvider: apifyMcpServer.options.paymentProvider,
userRentedActorIds,
}),
getUserInfoCached(apifyToken, apifyClient),
]);

if (actors.length === 0) {
return buildSearchActorsEmptyResponse(parsed.keywords);
}

const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors);
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier);
const structuredContent = {
actors: actorCardStructured,
query: parsed.keywords,
Expand Down
6 changes: 4 additions & 2 deletions src/tools/openai/fetch_actor_details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
fetchActorDetails,
} from '../../utils/actor_details.js';
import { buildMCPResponse } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import { fixActorNameInputAndLog } from '../core/actor_tools_factory.js';
import {
buildActorNotFoundResponse,
Expand All @@ -29,13 +30,14 @@ export const openaiFetchActorDetails: ToolEntry = Object.freeze({
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route: 'fetch-actor-details' });
const apifyClient = new ApifyClient({ token: apifyToken });

const cardOptions = buildCardOptions(resolveOutputOptions(parsed.output));
const { userPlanTier } = await getUserInfoCached(apifyToken, apifyClient);
const cardOptions = { ...buildCardOptions(resolveOutputOptions(parsed.output)), userTier: userPlanTier };
const details = await fetchActorDetails(apifyClient, actorName, cardOptions);
if (!details) {
return buildActorNotFoundResponse(actorName);
}

const { actorUrl, actorDetails } = buildActorDetailsForWidget(details);
const { actorUrl, actorDetails } = buildActorDetailsForWidget(details, userPlanTier);
const structuredContent = {
actorInfo: details.actorCardStructured,
inputSchema: details.inputSchema,
Expand Down
35 changes: 18 additions & 17 deletions src/tools/openai/search_actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@ import type { InternalToolArgs, ToolEntry } from '../../types.js';
import { formatActorForWidget, type WidgetActor } from '../../utils/actor_card.js';
import { searchAndFilterActors } from '../../utils/actor_search.js';
import { buildMCPResponse } from '../../utils/mcp.js';
import {
buildSearchActorsEmptyResponse,
buildSearchActorsResult,
searchActorsArgsSchema,
searchActorsMetadata,
} from '../core/search_actors_common.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import { buildSearchActorsEmptyResponse, buildSearchActorsResult, searchActorsArgsSchema, searchActorsMetadata } from '../core/search_actors_common.js';

/**
* OpenAI mode search-actors tool.
Expand All @@ -20,22 +16,27 @@ import {
export const openaiSearchActors: ToolEntry = Object.freeze({
...searchActorsMetadata,
call: async (toolArgs: InternalToolArgs) => {
const { args, apifyToken, userRentedActorIds, apifyMcpServer } = toolArgs;
const { args, apifyToken, apifyClient, userRentedActorIds, apifyMcpServer } = toolArgs;
const parsed = searchActorsArgsSchema.parse(args);
const actors = await searchAndFilterActors({
keywords: parsed.keywords,
apifyToken,
limit: parsed.limit,
offset: parsed.offset,
paymentProvider: apifyMcpServer.options.paymentProvider,
userRentedActorIds,
});
// Actor search and user-info fetch are independent; run in parallel to avoid a
// sequential round-trip on cache miss.
const [actors, { userPlanTier }] = await Promise.all([
searchAndFilterActors({
keywords: parsed.keywords,
apifyToken,
limit: parsed.limit,
offset: parsed.offset,
paymentProvider: apifyMcpServer.options.paymentProvider,
userRentedActorIds,
}),
getUserInfoCached(apifyToken, apifyClient),
]);

if (actors.length === 0) {
return buildSearchActorsEmptyResponse(parsed.keywords);
}

const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors);
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier);
const structuredContent: {
actors: typeof actorCardStructured;
query: string;
Expand All @@ -58,7 +59,7 @@ export const openaiSearchActors: ToolEntry = Object.freeze({
};

// Add widget-formatted actors for the interactive UI
structuredContent.widgetActors = actors.map(formatActorForWidget);
structuredContent.widgetActors = actors.map((actor) => formatActorForWidget(actor, userPlanTier));

const texts = [dedent`
# Search results:
Expand Down
22 changes: 19 additions & 3 deletions src/tools/structured_output_schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,31 @@ const tieredPricingSchema = {
export const pricingSchema = {
type: 'object' as const, // Literal type required for MCP SDK type compatibility
properties: {
model: { type: 'string', description: 'Pricing model (FREE, PRICE_PER_DATASET_ITEM, FLAT_PRICE_PER_MONTH, PAY_PER_EVENT)' },
isFree: { type: 'boolean', description: 'Whether the Actor is free to use' },
model: {
type: 'string',
description: 'Pricing model (FREE, PRICE_PER_DATASET_ITEM, FLAT_PRICE_PER_MONTH, PAY_PER_EVENT)',
},
userTier: {
type: 'string',
enum: ['FREE', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND'],
description: "The user's plan tier used to resolve pricing (always the user's tier, even if a different tier was used as fallback)",
},
pricePerUnit: { type: 'number', description: 'Price per unit (for non-free models)' },
unitName: { type: 'string', description: 'Unit name for pricing' },
trialMinutes: { type: 'number', description: 'Trial period in minutes' },
tieredPricing: tieredPricingSchema,
events: pricingEventsSchema,
pricingNote: { type: 'string', description: 'Note about additional pricing tiers available via fetch-actor-details' },
eventDescriptionsOmitted: {
type: 'boolean',
description: 'Whether event descriptions were omitted because the actor has many pricing events',
},
eventDescriptionsNote: {
type: 'string',
description: 'Note explaining that event descriptions were omitted and full details are available via fetch-actor-details',
},
},
required: ['model', 'isFree'],
required: ['model'],
Comment thread
jirispilka marked this conversation as resolved.
Outdated
};

/**
Expand Down
9 changes: 8 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { FAILURE_CATEGORY, TELEMETRY_ENV, TOOL_STATUS } from './const.js';
import type { ActorsMcpServer } from './mcp/server.js';
import type { PaymentProvider } from './payments/types.js';
import type { CATEGORY_NAMES } from './tools/categories.js';
import type { StructuredPricingInfo } from './utils/pricing_info.js';
import type { PricingTier, StructuredPricingInfo } from './utils/pricing_info.js';
import type { ProgressTracker } from './utils/progress.js';

export type SchemaProperties = {
Expand Down Expand Up @@ -616,6 +616,13 @@ export type ActorCardOptions = {
includeRating?: boolean;
/** Include metadata (developer, categories, last modified date, deprecation warning) */
includeMetadata?: boolean;
/** User's plan tier. Defaults to FREE inside the formatters when unset. */
userTier?: PricingTier;
/**
* true → filter `tieredPricing` down to the user's resolved tier (search-actors).
* false/undefined → keep the full tiered matrix (fetch-actor-details).
*/
simplifyPricingForUserTier?: boolean;
}

/**
Expand Down
26 changes: 20 additions & 6 deletions src/utils/actor_card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import type { Actor, ActorCardOptions, ActorStoreList, StructuredActorCard } fro
import {
getCurrentPricingInfo,
type PricingInfo,
pricingInfoToSimplifiedString,
pricingInfoToSimplifiedStructured,
pricingInfoToString,
pricingInfoToStructured,
type PricingTier,
type StructuredPricingInfo,
} from './pricing_info.js';

Expand Down Expand Up @@ -34,7 +37,7 @@ function getActorPricingInfo(actor: Actor | ActorStoreList): PricingInfo | null
return getCurrentPricingInfo(actor.pricingInfos || [], new Date());
}

const DEFAULT_CARD_OPTIONS: ActorCardOptions = {
export const DEFAULT_CARD_OPTIONS: ActorCardOptions = {
includeDescription: true,
includeStats: true,
includePricing: true,
Expand Down Expand Up @@ -169,6 +172,7 @@ export function formatActorToActorCard(
options: ActorCardOptions = DEFAULT_CARD_OPTIONS,
): string {
const data = extractActorData(actor, options);
const userTier = options.userTier ?? 'FREE';

const markdownLines = [
`## [${data.title}](${data.actorUrl}) (\`${data.actorFullName}\`)`,
Expand All @@ -180,7 +184,9 @@ export function formatActorToActorCard(
}

if (options.includePricing) {
const pricingString = pricingInfoToString(data.pricingInfo);
const pricingString = options.simplifyPricingForUserTier
? pricingInfoToSimplifiedString(data.pricingInfo, userTier)
: pricingInfoToString(data.pricingInfo);
markdownLines.push(`- **[Pricing](${data.actorUrl}/pricing):** ${pricingString}`);
}

Expand Down Expand Up @@ -223,6 +229,11 @@ export function formatActorToStructuredCard(
options: ActorCardOptions = DEFAULT_CARD_OPTIONS,
): StructuredActorCard {
const data = extractActorData(actor, options);
const userTier = options.userTier ?? 'FREE';

const pricing = options.simplifyPricingForUserTier
? pricingInfoToSimplifiedStructured(data.pricingInfo, userTier)
: pricingInfoToStructured(data.pricingInfo, userTier);

return {
title: data.title,
Expand All @@ -233,9 +244,7 @@ export function formatActorToStructuredCard(
developer: data.developer,
description: data.description,
categories: data.categories,
pricing: options.includePricing
? pricingInfoToStructured(data.pricingInfo)
: { model: 'FREE', isFree: true },
pricing,
stats: data.stats,
rating: data.rating,
modifiedAt: data.modifiedAt,
Expand Down Expand Up @@ -266,9 +275,14 @@ export type WidgetActor = {
/**
* Formats Actor for widget UI components.
* Used only in OpenAI (widget) mode — search results and Actor details widgets.
*
* Always uses simplified tier-aware pricing so the widget's top-level
* `pricePerUnit` / `events[0].priceUsd` (which is what the widget UI renders)
* matches the tier-filtered prices shown in the LLM text and structured output.
*/
export function formatActorForWidget(
actor: Actor | ActorStoreList,
userTier: PricingTier,
): WidgetActor {
const fullName = `${actor.username}/${actor.name}`;
return {
Expand All @@ -287,6 +301,6 @@ export function formatActorForWidget(
totalUsers: actor.stats?.totalUsers || 0,
},
url: `${APIFY_STORE_URL}/${fullName}`,
currentPricingInfo: pricingInfoToStructured(getActorPricingInfo(actor)),
currentPricingInfo: pricingInfoToSimplifiedStructured(getActorPricingInfo(actor), userTier),
};
}
6 changes: 4 additions & 2 deletions src/utils/actor_details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getActorMcpUrlCached } from './actor.js';
import { formatActorForWidget, formatActorToActorCard, formatActorToStructuredCard } from './actor_card.js';
import { searchActorsByKeywords } from './actor_search.js';
import { logHttpError } from './logging.js';
import type { PricingTier } from './pricing_info.js';

const ACTOR_DETAILS_PICTURE_SEARCH_LIMIT = 5;

Expand Down Expand Up @@ -109,13 +110,13 @@ export async function fetchActorDetails(
* Build the widget actor-details payload for the openai variant.
* Returns the Actor URL and the structured `actorDetails` object.
*/
export function buildActorDetailsForWidget(details: ActorDetailsResult) {
export function buildActorDetailsForWidget(details: ActorDetailsResult, userTier: PricingTier) {
const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`;
const formattedReadme = details.readme.replace(/^# /, `# [README](${actorUrl}/readme): `);
return {
actorUrl,
actorDetails: {
actorInfo: formatActorForWidget(details.actorInfo),
actorInfo: formatActorForWidget(details.actorInfo, userTier),
actorCard: details.actorCard,
readme: formattedReadme,
inputSchema: details.inputSchema,
Expand Down Expand Up @@ -183,6 +184,7 @@ export async function getMcpToolsMessage(
/**
* Build card options from resolved output flags.
* Maps boolean output flags to card rendering options (explicit true required).
* Caller adds `userTier` if needed — this helper stays focused on flags.
*/
export function buildCardOptions(output: {
description: boolean;
Expand Down
Loading
Loading