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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ Here are some special MCP operations and how the Apify MCP Server supports them:
- **Actor runs**: Get lists of your Actor runs, inspect their details, and retrieve logs.
- **Apify storage**: Access data from your datasets and key-value stores.
- **Actor tasks**: Create, inspect, and update your saved Actor tasks, and publish or unpublish their public landing pages.
- **Actor deploy**: Check the status of an Actor build and read the tail of its build log.

### Overview of available tools

Expand Down Expand Up @@ -284,6 +285,7 @@ Legend for the **Enabled by default** column:
| `update-actor-task` | tasks | Update a task's input, run options, or public display configuration. | |
| `publish-actor-task` | tasks | Publish a task on its public landing page. | |
| `unpublish-actor-task` | tasks | Unpublish a task from its public landing page. | |
| `get-actor-build` | deploy | Get an Actor build's status and the last lines of its build log. | |

> **Note:**
>
Expand Down
1 change: 1 addition & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const SERVER_MODE_AUTO_DETECTION_ENABLED = true;
export const SERVER_NAME = 'apify-mcp-server';
export const SERVER_TITLE = 'Apify MCP Server';
export const HELPER_TOOLS = {
ACTOR_BUILD_GET: 'get-actor-build',
ACTOR_CALL: 'call-actor',
ACTOR_CALL_WIDGET: 'call-actor-widget',
ACTOR_GET_DETAILS: 'fetch-actor-details',
Expand Down
2 changes: 2 additions & 0 deletions src/tools/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic.
- `storage/` — dataset and key-value-store tools plus `storage_helpers.ts`.
- `tasks/` — Actor task create/get/update plus publish/unpublish of the task's public
landing page (`task_helpers.ts` holds the shared task response shape and the publication call).
- `deploy/` — `get-actor-build` (build status plus a log tail); `build_helpers.ts` holds the
allowlisted build result shape.
- `docs/` — search and fetch Apify docs.
- `dev/` — the `report-problem` tool for reporting a problem with a tool or Actor.
- `widgets/` — the `*-widget` tool variants (apps mode only).
Expand Down
23 changes: 23 additions & 0 deletions src/tools/deploy/build_helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Build } from 'apify-client';

import type { ConsoleLinkContext } from '../../types.js';
import { buildConsoleBuildUrl } from '../../utils/console_link.js';
import { toIsoString } from '../actors/actor_run_response.js';

/**
* The build subset returned by the deploy tools. Allowlisted so internal fields on the API
* document (userId, meta, options, inspectorId) never reach the client.
* `apifyConsoleUrl` is set only for Console UI token sessions (see `getConsoleLinkContext`).
*/
export function toBuildResult(build: Build, linkContext: ConsoleLinkContext | undefined) {
return {
id: build.id,
actorId: build.actId,
buildNumber: build.buildNumber,
status: build.status,
// Normalized because the client parses these into `Date` objects; the output schema promises strings.
startedAt: toIsoString(build.startedAt) ?? null,
finishedAt: toIsoString(build.finishedAt) ?? null,
apifyConsoleUrl: buildConsoleBuildUrl(linkContext, build.actId, build.id),
};
}
103 changes: 103 additions & 0 deletions src/tools/deploy/get_actor_build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Build } from 'apify-client';
import { z } from 'zod';

import type { ApifyClient } from '../../apify_client.js';
import { HELPER_TOOLS } from '../../const.js';
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
import { TOOL_TYPE } from '../../types.js';
import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js';
import { getConsoleLinkContext } from '../../utils/console_link.js';
import { respondOk, respondUserError } from '../../utils/mcp.js';
import { TERMINAL_RUN_STATUSES } from '../../utils/progress.js';
import { apifyConsoleLinkText } from '../storage/storage_helpers.js';
import { getActorBuildToolOutputSchema } from '../structured_output_schemas.js';
import { toBuildResult } from './build_helpers.js';

const getActorBuildArgs = z.object({
buildId: z.string().min(1).describe('Build ID, as returned when a build is started'),
lines: z
.number()
.int()
.min(0)
.max(50)
.default(20)
.describe('Output the last N lines of the build log; pass 0 to return the entire log'),
});

/**
* https://docs.apify.com/api/v2/actor-build-log-get
* /v2/actor-builds/{buildId}/log
*/
async function fetchLogTail(client: ApifyClient, buildId: string, lines: number): Promise<string[]> {
const log = await client.build(buildId).log().get();
if (!log) return [];
// Logs from the API end with a newline; drop it so the tail slice counts only content lines.
// slice(-0) is slice(0), so lines 0 returns the whole log, the same as get-actor-log.
return log.replace(/\n$/, '').split('\n').slice(-lines);
}

function buildNextStep(build: Build, loadedToolNames: readonly string[]): string {
if (build.status === 'SUCCEEDED') {
return loadedToolNames.includes(HELPER_TOOLS.ACTOR_CALL)
? `Run the Actor with ${HELPER_TOOLS.ACTOR_CALL} and set callOptions.build to ${build.buildNumber}.`
: 'The build is ready to run.';
}
if (TERMINAL_RUN_STATUSES.has(build.status)) {
return 'Read the log tail for the error, fix the source, and build again.';
}
return 'Call this tool again in about 10 seconds.';
}

/**
* https://docs.apify.com/api/v2/actor-build-get
* /v2/actor-builds/{buildId}
*/
export const getActorBuild: ToolEntry = Object.freeze({
type: TOOL_TYPE.INTERNAL,
name: HELPER_TOOLS.ACTOR_BUILD_GET,
title: 'Get Actor build',
description: `Get the status of an Actor build and the last lines of its build log.
Read-only. Returns the build (id, actorId, buildNumber, status, startedAt, finishedAt), the log tail,
and a summary with one next step.

USAGE:
- Use to check whether a build has finished.
- Use when a build FAILED to read the error from the log tail before fixing the source.

USAGE EXAMPLES:
- user_input: Did build 7aB3xYz9Kq finish?
- user_input: Why did build 7aB3xYz9Kq fail?`,
// `fixZodSchemaRequired` strips `lines` from `required` because it has a default.
inputSchema: fixZodSchemaRequired(z.toJSONSchema(getActorBuildArgs)) as ToolInputSchema,
outputSchema: getActorBuildToolOutputSchema,
ajvValidate: compileSchema(z.toJSONSchema(getActorBuildArgs)),
paymentRequired: true,
annotations: {
title: 'Get Actor build',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
call: async (toolArgs: InternalToolArgs) => {
const { args, apifyClient: client, apifyToken, loadedToolNames } = toolArgs;
const parsed = getActorBuildArgs.parse(args);
const build = await client.build(parsed.buildId).get();
if (!build) {
return respondUserError(`Build with ID '${parsed.buildId}' not found.`);
}
const logTail = await fetchLogTail(client, parsed.buildId, parsed.lines);
const linkContext = await getConsoleLinkContext(apifyToken, client);
const structuredContent = { build: toBuildResult(build, linkContext), logTail };
const summary = `Build ${build.buildNumber} of Actor ${build.actId} is ${build.status}.`;
const consoleLinkText = apifyConsoleLinkText(structuredContent.build.apifyConsoleUrl);
return respondOk(
[
JSON.stringify(structuredContent),
`${summary}\n${buildNextStep(build, loadedToolNames)}`,
...(consoleLinkText ? [consoleLinkText] : []),
],
{ structuredContent },
);
},
} as const);
2 changes: 2 additions & 0 deletions src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { SERVER_MODE } from '../types.js';
import { callActorApps, callActorDefault } from './actors/call_actor.js';
import { fetchActorDetails } from './actors/fetch_actor_details.js';
import { searchActors } from './actors/search_actors.js';
import { getActorBuild } from './deploy/get_actor_build.js';
import { reportProblem } from './dev/report_problem.js';
import { fetchApifyDocs } from './docs/fetch_apify_docs.js';
import { searchApifyDocs } from './docs/search_apify_docs.js';
Expand Down Expand Up @@ -88,6 +89,7 @@ export const toolCategories = {
getKeyValueStoreList,
],
tasks: [createActorTask, getActorTask, updateActorTask, publishActorTask, unpublishActorTask],
deploy: [getActorBuild],
dev: [reportProblem],
} satisfies Record<string, CategoryToolEntry[]>;

Expand Down
31 changes: 31 additions & 0 deletions src/tools/structured_output_schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,37 @@ export const getActorRunLogToolOutputSchema = {
required: ['log'],
};

/**
* Schema for get-actor-build: the allowlisted build subset (`toBuildResult`) plus the log tail.
*/
export const getActorBuildToolOutputSchema = {
type: 'object' as const,
properties: {
build: {
type: 'object',
properties: {
id: { type: 'string', description: 'Build ID' },
actorId: { type: 'string', description: 'ID of the Actor the build belongs to' },
buildNumber: { type: 'string', description: 'Build number, e.g. 0.1.12' },
status: { type: 'string', description: 'Build status, e.g. RUNNING, SUCCEEDED, FAILED' },
startedAt: { type: ['string', 'null'], description: 'ISO timestamp' },
finishedAt: { type: ['string', 'null'], description: 'ISO timestamp; null while the build is running' },
apifyConsoleUrl: {
type: 'string',
description: 'Personalized Apify Console link to the build; present only for Console sessions',
},
},
required: ['id', 'actorId', 'buildNumber', 'status', 'startedAt', 'finishedAt'],
},
logTail: {
type: 'array',
items: { type: 'string' },
description: 'The last N lines of the build log; empty when lines=0 or the log is empty',
},
},
required: ['build', 'logTail'],
};

// Per-storage entry shapes. Factories (not shared constants) because `structuredClone` preserves
// object identity: if `default` and `additionalProperties` referenced the same object, cloning
// `actorRunOutputSchema` would keep them as the same object, and injecting `itemsSchema` into
Expand Down
9 changes: 9 additions & 0 deletions src/utils/console_link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export function buildConsoleRunUrl(context: ConsoleLinkContext | undefined, runI
return buildConsoleUrl(context, `/actors/runs/${runId}`);
}

/** Builds the Console build detail URL: `<consoleBaseUrl>[/organization/<orgId>]/actors/<actorId>/builds/<buildId>`. */
export function buildConsoleBuildUrl(
context: ConsoleLinkContext | undefined,
actorId: string,
buildId: string,
): string | undefined {
return buildConsoleUrl(context, `/actors/${actorId}/builds/${buildId}`);
}

/** Builds the Console dataset URL: `<consoleBaseUrl>[/organization/<orgId>]/storage/datasets/<datasetId>`. */
export function buildConsoleDatasetUrl(context: ConsoleLinkContext | undefined, datasetId: string): string | undefined {
return buildConsoleUrl(context, `/storage/datasets/${datasetId}`);
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/console_link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ApifyClient } from '../../src/apify_client.js';
import { STAGING_MCP_HOSTNAME } from '../../src/const.js';
import {
buildConsoleActorUrl,
buildConsoleBuildUrl,
buildConsoleDatasetUrl,
buildConsoleKeyValueStoreUrl,
buildConsoleRunUrl,
Expand Down Expand Up @@ -58,6 +59,9 @@ describe('buildConsole*Url (production host)', () => {
it('builds personal Actor/run/dataset/key-value-store URLs', () => {
expect(buildConsoleActorUrl({}, 'ACTOR_ID')).toBe('https://console.apify.com/actors/ACTOR_ID');
expect(buildConsoleRunUrl({}, 'RUN_ID')).toBe('https://console.apify.com/actors/runs/RUN_ID');
expect(buildConsoleBuildUrl({}, 'ACTOR_ID', 'BUILD_ID')).toBe(
'https://console.apify.com/actors/ACTOR_ID/builds/BUILD_ID',
);
expect(buildConsoleDatasetUrl({}, 'DATASET_ID')).toBe('https://console.apify.com/storage/datasets/DATASET_ID');
expect(buildConsoleKeyValueStoreUrl({}, 'STORE_ID')).toBe(
'https://console.apify.com/storage/key-value-stores/STORE_ID',
Expand All @@ -72,6 +76,9 @@ describe('buildConsole*Url (production host)', () => {
expect(buildConsoleRunUrl(org, 'RUN_ID')).toBe(
'https://console.apify.com/organization/ORG_ID/actors/runs/RUN_ID',
);
expect(buildConsoleBuildUrl(org, 'ACTOR_ID', 'BUILD_ID')).toBe(
'https://console.apify.com/organization/ORG_ID/actors/ACTOR_ID/builds/BUILD_ID',
);
expect(buildConsoleDatasetUrl(org, 'DATASET_ID')).toBe(
'https://console.apify.com/organization/ORG_ID/storage/datasets/DATASET_ID',
);
Expand All @@ -83,6 +90,7 @@ describe('buildConsole*Url (production host)', () => {
it('returns undefined without a context (non-Console session)', () => {
expect(buildConsoleActorUrl(undefined, 'ACTOR_ID')).toBeUndefined();
expect(buildConsoleRunUrl(undefined, 'RUN_ID')).toBeUndefined();
expect(buildConsoleBuildUrl(undefined, 'ACTOR_ID', 'BUILD_ID')).toBeUndefined();
expect(buildConsoleDatasetUrl(undefined, 'DATASET_ID')).toBeUndefined();
expect(buildConsoleKeyValueStoreUrl(undefined, 'STORE_ID')).toBeUndefined();
});
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/tools.categories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('getCategoryTools', () => {
const defaultResult = getCategoryTools('default');
const appsResult = getCategoryTools('apps');

const modeIndependentCategories: ToolCategory[] = ['docs', 'storage', 'tasks', 'dev'];
const modeIndependentCategories: ToolCategory[] = ['docs', 'storage', 'tasks', 'deploy', 'dev'];
for (const cat of modeIndependentCategories) {
expect(defaultResult[cat]).toEqual(appsResult[cat]);
}
Expand Down
Loading
Loading