Skip to content
Draft
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
5 changes: 4 additions & 1 deletion src/mcp/tool_dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { logHttpError } from '../utils/logging.js';
import { respondErrorNoTelemetry } from '../utils/mcp.js';
import type { createProgressTracker } from '../utils/progress.js';
import { applyToolTelemetry, buildExecutionDiagnostics } from '../utils/tool_status.js';
import { buildActorFields } from '../utils/tools.js';
import { buildActorFields, extractActorId } from '../utils/tools.js';
import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC } from './const.js';
import type { RemoteMcpCallOutcome } from './remote_tool_call.js';
import { withRemoteMcpClient } from './remote_tool_call.js';
Expand Down Expand Up @@ -108,6 +108,9 @@ export async function dispatchToolCall(params: {
actorStore,
paymentProvider,
loadedToolNames: Array.from(tools.keys()),
loadedActorIds: new Set(
Array.from(tools.values(), extractActorId).filter((id): id is string => id !== undefined),
),
progressTracker,
mcpSessionId,
taskMode,
Expand Down
13 changes: 13 additions & 0 deletions src/tools/actor_tool_naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';

import log from '@apify/log';

import { HELPER_TOOLS } from '../const.js';
import { MAX_TOOL_NAME_LENGTH, TOOL_NAME_HASH_LENGTH } from '../mcp/const.js';
import type { ActorInfo } from '../types.js';
import { ACTOR_TOOL_MODE } from '../types.js';
Expand Down Expand Up @@ -79,3 +80,15 @@ export function legacyToolNameToNew(name: string): string | null {
export function getToolSchemaID(actorName: string): string {
return `https://apify.com/mcp/${actorNameToToolName(actorName)}/schema.json`;
}

/**
* Whether this session can run this Actor: call-actor loaded, or the Actor's own tool loaded
* (direct or Actor-MCP). Soft check for a guidance hint, not a hard gate.
*/
export function canRunActor(
actorId: string,
loadedToolNames: readonly string[],
loadedActorIds: ReadonlySet<string>,
): boolean {
return loadedToolNames.includes(HELPER_TOOLS.ACTOR_CALL) || loadedActorIds.has(actorId);
}
29 changes: 28 additions & 1 deletion src/tools/actors/fetch_actor_details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { buildConsoleActorUrl, getConsoleLinkContext, VERBATIM_LINKS_NUDGE } fro
import { wrapJsonText } from '../../utils/encode_text.js';
import { respondOk, respondUserError, type ToolResponse } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import { canRunActor } from '../actor_tool_naming.js';
import { actorDetailsOutputSchema } from '../structured_output_schemas.js';
import { fixActorNameInputAndLog } from './actor_tools_factory.js';

Expand Down Expand Up @@ -150,6 +151,19 @@ export function buildActorNotFoundResponse(actorName: string, loadedToolNames: r
);
}

/** Guidance appended when the Actor exists but `canRunActor` says this session can't run it. */
export function buildActorNotRunnableGuidance(
actorId: string,
loadedToolNames: readonly string[],
loadedActorIds: ReadonlySet<string>,
): string {
if (canRunActor(actorId, loadedToolNames, loadedActorIds)) return '';
return dedent`
This Actor is not exposed as a tool and cannot be run in this configuration. Open its
Apify page or configure it separately to use it.
`;
}

/**
* Build text and structured response for actor details.
* Pure/sync: the caller pre-resolves `mcpToolsMessage` when `output.mcpTools` is true.
Expand Down Expand Up @@ -231,7 +245,16 @@ export function buildActorDetailsTextResponse(options: {
* Returns the same text + structured response in both modes.
*/
export async function buildFetchActorDetailsResult(toolArgs: InternalToolArgs): Promise<ToolResponse> {
const { args, apifyToken, apifyClient, actorStore, paymentProvider, mcpSessionId, loadedToolNames } = toolArgs;
const {
args,
apifyToken,
apifyClient,
actorStore,
paymentProvider,
mcpSessionId,
loadedToolNames,
loadedActorIds,
} = toolArgs;
const parsed = fetchActorDetailsToolArgsSchema.parse(args);
const actorName = fixActorNameInputAndLog(parsed.actor, { mcpSessionId, route: HELPER_TOOLS.ACTOR_GET_DETAILS });

Expand Down Expand Up @@ -271,6 +294,10 @@ export async function buildFetchActorDetailsResult(toolArgs: InternalToolArgs):
linkContext,
});

// Resolved Actor ID, not the raw `actor` input β€” that may itself be an ID.
const runnableGuidance = buildActorNotRunnableGuidance(details.actorInfo.id, loadedToolNames, loadedActorIds);
if (runnableGuidance) texts.push(runnableGuidance);

return respondOk(texts, { structuredContent });
}

Expand Down
33 changes: 28 additions & 5 deletions src/tools/actors/search_actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getConsoleLinkContext, VERBATIM_LINKS_NUDGE } from '../../utils/console
import { respondOk } from '../../utils/mcp.js';
import type { PricingTier } from '../../utils/pricing_info.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import { canRunActor } from '../actor_tool_naming.js';
import { actorSearchOutputSchema } from '../structured_output_schemas.js';

/**
Expand Down Expand Up @@ -130,6 +131,21 @@ export function buildNoActorsFoundInstructions(keywords: string): string {
`;
}

/** Caveat for the whole result list β€” appended only when at least one result lacks a run path. */
export function buildActorCallabilityCaveat(
actorIds: readonly string[],
loadedToolNames: readonly string[],
loadedActorIds: ReadonlySet<string>,
): string {
const anyResultCannotRun = actorIds.some((id) => !canRunActor(id, loadedToolNames, loadedActorIds));
if (!anyResultCannotRun) return '';
return dedent`
This session can run only Actors already exposed as dedicated tools. Other Actors found
here are informational and cannot be run in this configuration. To use another Actor, open
its Apify page or configure it separately.
`;
}

/**
* Builds the footer/instructions guidance for successful search results.
* Interpolates the verbatim links nudge if applicable.
Expand All @@ -139,7 +155,12 @@ export function buildNoActorsFoundInstructions(keywords: string): string {
* otherwise be told to call a tool absent from its own `tools/list`.
* See apify/apify-mcp-server#1296.
*/
export function buildSearchActorsFooter(verbatimLinksNudge: string, loadedToolNames: readonly string[]): string {
export function buildSearchActorsFooter(
verbatimLinksNudge: string,
actorIds: readonly string[],
loadedToolNames: readonly string[],
loadedActorIds: ReadonlySet<string>,
): string {
const detailsHint = loadedToolNames.includes(HELPER_TOOLS.ACTOR_GET_DETAILS)
? dedent`
If you need more detailed information about any of these Actors, including their input
Expand All @@ -152,7 +173,8 @@ export function buildSearchActorsFooter(verbatimLinksNudge: string, loadedToolNa
(e.g., just the platform name like "TikTok" instead of "TikTok posts") to make sure
you haven't missed a better Actor.${verbatimLinksNudge}
`;
return detailsHint ? `${detailsHint}\n${secondSearch}` : secondSearch;
const callabilityCaveat = buildActorCallabilityCaveat(actorIds, loadedToolNames, loadedActorIds);
return [detailsHint, secondSearch, callabilityCaveat].filter(Boolean).join('\n');
}

/**
Expand All @@ -176,7 +198,7 @@ export const searchActors: ToolEntry = Object.freeze({
openWorldHint: false,
},
call: async (toolArgs: InternalToolArgs) => {
const { args, apifyToken, apifyClient, paymentProvider, loadedToolNames } = toolArgs;
const { args, apifyToken, apifyClient, paymentProvider, loadedToolNames, loadedActorIds } = toolArgs;
const parsed = searchActorsBaseArgsSchema.parse(args);
// Actor search and user-info fetch are independent; run in parallel to avoid a
// sequential round-trip on cache miss.
Expand All @@ -202,12 +224,14 @@ export const searchActors: ToolEntry = Object.freeze({
const linkContext = await getConsoleLinkContext(apifyToken, apifyClient);
const { actorCardText, actorCardStructured } = buildSearchActorsResult(actors, userPlanTier, linkContext);
const verbatimLinksNudge = linkContext ? `\n${VERBATIM_LINKS_NUDGE}` : '';
const actorIds = actors.map((actor) => actor.id);
const footer = buildSearchActorsFooter(verbatimLinksNudge, actorIds, loadedToolNames, loadedActorIds);
const structuredContent = {
actors: actorCardStructured,
query: parsed.keywords,
count: actors.length,
userTier: userPlanTier,
instructions: buildSearchActorsFooter(verbatimLinksNudge, loadedToolNames),
instructions: footer,
};

// Build header and footer with separate `dedent` calls and concatenate around
Expand All @@ -221,7 +245,6 @@ export const searchActors: ToolEntry = Object.freeze({

# Actors:
`;
const footer = buildSearchActorsFooter(verbatimLinksNudge, loadedToolNames);
return respondOk(`${header}\n\n${actorCardText}\n\n${footer}`, { structuredContent });
},
} as const satisfies HelperTool);
11 changes: 9 additions & 2 deletions src/tools/widgets/fetch_actor_details_widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { compileSchema } from '../../utils/ajv.js';
import { respondOk } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import { fixActorNameInputAndLog } from '../actors/actor_tools_factory.js';
import { actorDetailsOutputDefaults, buildActorNotFoundResponse } from '../actors/fetch_actor_details.js';
import {
actorDetailsOutputDefaults,
buildActorNotFoundResponse,
buildActorNotRunnableGuidance,
} from '../actors/fetch_actor_details.js';
import { actorDetailsWidgetOutputSchema } from '../structured_output_schemas.js';

const widgetConfig = getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS);
Expand Down Expand Up @@ -63,7 +67,7 @@ export const fetchActorDetailsWidget: ToolEntry = Object.freeze({
openWorldHint: false,
},
call: async (toolArgs: InternalToolArgs) => {
const { apifyToken, apifyClient, mcpSessionId, loadedToolNames } = toolArgs;
const { apifyToken, apifyClient, mcpSessionId, loadedToolNames, loadedActorIds } = toolArgs;
const parsed = fetchActorDetailsWidgetArgsSchema.parse(toolArgs.args);
const actorName = fixActorNameInputAndLog(parsed.actor, {
mcpSessionId,
Expand Down Expand Up @@ -95,6 +99,9 @@ export const fetchActorDetailsWidget: ToolEntry = Object.freeze({
An interactive widget has been rendered with detailed Actor information.
`,
];
// Resolved Actor ID, not the raw `actor` input.
const runnableGuidance = buildActorNotRunnableGuidance(details.actorInfo.id, loadedToolNames, loadedActorIds);
if (runnableGuidance) texts.push(runnableGuidance);

return respondOk(texts, {
structuredContent,
Expand Down
9 changes: 8 additions & 1 deletion src/tools/widgets/search_actors_widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { compileSchema } from '../../utils/ajv.js';
import { respondOk } from '../../utils/mcp.js';
import { getUserInfoCached } from '../../utils/userid_cache.js';
import {
buildActorCallabilityCaveat,
buildNoActorsFoundInstructions,
buildSearchActorsResult,
searchActorsBaseArgsSchema,
Expand Down Expand Up @@ -58,7 +59,7 @@ export const searchActorsWidget: ToolEntry = Object.freeze({
openWorldHint: false,
},
call: async (toolArgs: InternalToolArgs) => {
const { args, apifyToken, apifyClient, paymentProvider } = toolArgs;
const { args, apifyToken, apifyClient, paymentProvider, loadedToolNames, loadedActorIds } = toolArgs;
const parsed = searchActorsWidgetArgsSchema.parse(args);
// Actor search and user-info fetch are independent; run in parallel to avoid a
// sequential round-trip on cache miss.
Expand Down Expand Up @@ -107,6 +108,12 @@ export const searchActorsWidget: ToolEntry = Object.freeze({
in your response.
`,
];
const callabilityCaveat = buildActorCallabilityCaveat(
actors.map((actor) => actor.id),
loadedToolNames,
loadedActorIds,
);
if (callabilityCaveat) texts.push(callabilityCaveat);

const widgetConfig = getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS);
return respondOk(texts, {
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export type InternalToolArgs = {
paymentProvider?: PaymentProvider;
/** Names of all currently loaded tools. */
loadedToolNames: readonly string[];
/** IDs of Actors backing all currently loaded Actor / Actor-MCP tools. */
loadedActorIds: ReadonlySet<string>;
/** Optional progress tracker for long running internal tools, like call-actor */
progressTracker?: ProgressTracker | null;
/** MCP session ID for logging context */
Expand Down
15 changes: 4 additions & 11 deletions tests/unit/mcp.server.stateless_instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,10 @@ describe('ActorsMcpServer.getStatelessServerInstructions()', () => {
expect(instructions).not.toContain(RAG_WEB_BROWSER);
});

it('pins the Claude-connector tool surface: call-actor absent, its own dedicated Actor tools present', () => {
const url =
'http://localhost/?tools=search-actors,search-actors-widget,fetch-actor-details,fetch-actor-details-widget,search-apify-docs,fetch-apify-docs,get-actor-run,get-actor-run-widget,get-actor-run-list,get-actor-log,abort-actor-run,get-dataset-list,get-dataset,get-dataset-items,get-key-value-store-list,get-key-value-store,get-key-value-store-record,apify/rag-web-browser,apify/web-fetch';
const instructions = makeServer().getStatelessServerInstructions(url);
expect(instructions).not.toContain(HELPER_TOOLS.ACTOR_CALL);
expect(instructions).toContain(RAG_WEB_BROWSER);
expect(instructions).toContain(WEB_FETCH);
});

it('never mentions report-problem via a requestUrl β€” not derivable, identity-dependent', () => {
const instructions = makeServer().getStatelessServerInstructions('http://localhost/?tools=search-actors');
// report-problem is identity-dependent, never derivable from the URL alone; ?tools=dev puts
// it in the candidate set so this actually exercises the filter, not an always-true check.
it('never mentions report-problem via a requestUrl, even when explicitly selected', () => {
const instructions = makeServer().getStatelessServerInstructions('http://localhost/?tools=dev');
expect(instructions).not.toContain(HELPER_TOOLS.PROBLEM_REPORT);
});
});
23 changes: 22 additions & 1 deletion tests/unit/tools.actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import { createHash } from 'node:crypto';

import { describe, expect, it } from 'vitest';

import { HELPER_TOOLS } from '../../src/const.js';
import { MAX_TOOL_NAME_LENGTH, TOOL_NAME_HASH_LENGTH } from '../../src/mcp/const.js';
import { actorNameToToolName, legacyToolNameToNew, resolveActorToolMode } from '../../src/tools/actor_tool_naming.js';
import {
actorNameToToolName,
canRunActor,
legacyToolNameToNew,
resolveActorToolMode,
} from '../../src/tools/actor_tool_naming.js';
import type { ActorInfo, ActorInputSchema } from '../../src/types.js';
import { ACTOR_TOOL_MODE } from '../../src/types.js';

Expand Down Expand Up @@ -121,3 +127,18 @@ describe('resolveActorToolMode()', () => {
expect(resolveActorToolMode(makeActorInfo({ input: NON_EMPTY_INPUT, ...opts }))).toBe(expected);
});
});

describe('canRunActor()', () => {
it('returns true when call-actor is loaded, regardless of the Actor', () => {
expect(canRunActor('actor-id-1', [HELPER_TOOLS.ACTOR_CALL], new Set())).toBe(true);
});

it('returns true when call-actor is absent but the Actor ID is in loadedActorIds', () => {
expect(canRunActor('actor-id-1', [], new Set(['actor-id-1']))).toBe(true);
});

it('returns false when call-actor is absent and the Actor ID is not in loadedActorIds', () => {
expect(canRunActor('actor-id-1', [], new Set(['other-actor-id']))).toBe(false);
expect(canRunActor('actor-id-1', [], new Set())).toBe(false);
});
});
50 changes: 49 additions & 1 deletion tests/unit/tools.fetch_actor_details.response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
actorDetailsOutputDefaults,
buildActorDetailsTextResponse,
buildActorNotFoundResponse,
buildActorNotRunnableGuidance,
buildFetchActorDetailsResult,
} from '../../src/tools/actors/fetch_actor_details.js';
import type { ActorDetailsResult } from '../../src/utils/actor_details.js';
Expand Down Expand Up @@ -178,9 +179,12 @@ describe('buildFetchActorDetailsResult()', () => {
});

// pricing: false β†’ the users/me lookup is needed only for Console UI tokens.
// call-actor loaded so these unrelated assertions aren't perturbed by the not-runnable guidance.
const callWithToken = async (apifyToken: string) => {
const result = await buildFetchActorDetailsResult({
...stubInternalToolArgs({ actor: 'apify/example-mcp-server', output: { inputSchema: true } }),
...stubInternalToolArgs({ actor: 'apify/example-mcp-server', output: { inputSchema: true } }, [
HELPER_TOOLS.ACTOR_CALL,
]),
apifyToken,
});
return result as { content: { type: string; text: string }[] };
Expand Down Expand Up @@ -236,6 +240,50 @@ describe('buildFetchActorDetailsResult()', () => {

await expect(callWithToken('apify_api_test')).rejects.toMatchObject({ statusCode: 401 });
});

it('appends not-runnable guidance when neither call-actor nor a dedicated tool is loaded', async () => {
const result = await buildFetchActorDetailsResult(
stubInternalToolArgs({ actor: 'apify/example-mcp-server', output: { inputSchema: true } }),
);
const text = ((result as { content: { text: string }[] }).content ?? []).map((c) => c.text).join('\n');

expect(text).toContain('This Actor is not exposed as a tool and cannot be run in this configuration.');
});

it('omits not-runnable guidance when call-actor is loaded', async () => {
const result = await buildFetchActorDetailsResult(
stubInternalToolArgs({ actor: 'apify/example-mcp-server', output: { inputSchema: true } }, [
HELPER_TOOLS.ACTOR_CALL,
]),
);
const text = ((result as { content: { text: string }[] }).content ?? []).map((c) => c.text).join('\n');

expect(text).not.toContain('cannot be run in this configuration');
});

it('omits not-runnable guidance when the Actor has its own dedicated tool loaded', async () => {
const result = await buildFetchActorDetailsResult(
stubInternalToolArgs(
{ actor: 'apify/example-mcp-server', output: { inputSchema: true } },
[],
[MOCK_DETAILS.actorInfo.id],
),
);
const text = ((result as { content: { text: string }[] }).content ?? []).map((c) => c.text).join('\n');

expect(text).not.toContain('cannot be run in this configuration');
});
});

describe('buildActorNotRunnableGuidance()', () => {
it('is empty when call-actor is loaded', () => {
expect(buildActorNotRunnableGuidance('actor-id-1', [HELPER_TOOLS.ACTOR_CALL], new Set())).toBe('');
});

it('names no tool, only states the Actor cannot run, when neither is loaded', () => {
const text = buildActorNotRunnableGuidance('actor-id-1', [], new Set());
expect(text).toContain('cannot be run in this configuration');
});
});

// Result text has no `hasTool`, so tools.mode_contract.test.ts does not cover it. A guessed Actor
Expand Down
Loading
Loading