Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f797a05
feat: add Apify plugin
huamanraj Jul 3, 2026
c77dcf2
test: wire Apify into demo testing
huamanraj Jul 3, 2026
9c96465
fix: address Apify review feedback
huamanraj Jul 3, 2026
9271f9b
Merge branch 'main' into feat/apify-plugin
ambikeesshh Jul 15, 2026
71cbfb1
revert(apify): drop demo/testing wiring out of plugin scope
ambikeesshh Jul 15, 2026
214c95e
fix(apify): correct browserInfoDelete risk metadata
ambikeesshh Jul 15, 2026
978a978
fix(apify): export EndpointInputSchemas/EndpointOutputSchemas
ambikeesshh Jul 15, 2026
cef9bec
test(apify): add jest wiring and operation suite
ambikeesshh Jul 15, 2026
88bc8fb
fix(apify): sync pnpm-lock with main after merge
ambikeesshh Jul 15, 2026
c28c9bf
fix(apify): nest charge/resurrect ops under actorRun
ambikeesshh Jul 15, 2026
39ab998
test(apify): exercise endpoint invocation and request building
ambikeesshh Jul 15, 2026
600181a
test(apify): cover endpoint invocation across all namespaces
ambikeesshh Jul 15, 2026
ba8b6b5
merge(main): sync apify plugin branch with upstream main
ambikeesshh Jul 23, 2026
71ec835
fix(apify): guard logging failures and keep DEFAULT error handler last
ambikeesshh Jul 23, 2026
fe5a81f
fix(apify): repair pnpm-lock after main merge (ts-jest importer)
ambikeesshh Jul 23, 2026
d28ca3d
Merge branch 'main' into feat/apify-plugin
ambikeesshh Jul 24, 2026
a106074
fix(apify): trim ops to OSS listing (113) and drop comments
ambikeesshh Jul 24, 2026
8021afe
fix(apify): restore 429 retries and document unknown types
ambikeesshh Jul 24, 2026
590d1a1
Merge main into feat/apify-plugin
Dhirenderchoudhary Aug 12, 2026
57cb810
merge(main): keep apify MCP and add REST ops
ambikeesshh Aug 12, 2026
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
1,472 changes: 1,472 additions & 0 deletions packages/apify/endpoints/operations.ts

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions packages/apify/endpoints/rest-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from 'zod';

export const ApifyOperationInputSchema = z
.object({
// Apify request bodies differ per endpoint and are passed through as-is.
body: z.unknown().optional(),
// Query values are endpoint-specific and not shared across operations.
query: z.record(z.string(), z.unknown()).optional(),
// Custom headers are forwarded without a fixed provider-wide shape.
headers: z.record(z.string(), z.unknown()).optional(),
contentType: z.string().optional(),
mediaType: z.string().optional(),
})
.loose();

// Apify responses are endpoint-specific and are not normalized here.
export const ApifyOperationOutputSchema = z.unknown();

export type ApifyOperationInput = z.infer<typeof ApifyOperationInputSchema>;
export type ApifyOperationOutput = z.infer<typeof ApifyOperationOutputSchema>;

export type ApifyEndpointInputs = Record<string, ApifyOperationInput>;
export type ApifyEndpointOutputs = Record<string, ApifyOperationOutput>;

export const EndpointInputSchemas = ApifyOperationInputSchema;
export const EndpointOutputSchemas = ApifyOperationOutputSchema;
146 changes: 146 additions & 0 deletions packages/apify/endpoints/rest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type {
CorsairEndpoint,
EndpointMetaEntry,
RequiredPluginEndpointMeta,
RequiredPluginEndpointSchemas,
} from 'corsair/core';
import { logEventFromContext } from 'corsair/core';
import { z } from 'zod';
import type { ApifyMcpContext } from '../index';
import { makeApifyRequest } from '../rest-client';
import type {
ApifyOperationDefinition,
ApifyOperationTree,
} from './operations';
import { apifyOperations } from './operations';
import type { ApifyOperationInput, ApifyOperationOutput } from './rest-types';
import { ApifyOperationOutputSchema } from './rest-types';

type ApifyEndpoint = CorsairEndpoint<
ApifyMcpContext,
ApifyOperationInput,
ApifyOperationOutput
>;

export type ApifyEndpointTree<T extends ApifyOperationTree> = {
[K in keyof T]: T[K] extends ApifyOperationDefinition
? ApifyEndpoint
: T[K] extends ApifyOperationTree
? ApifyEndpointTree<T[K]>
: never;
};

function isOperationDefinition(
value: ApifyOperationDefinition | ApifyOperationTree,
): value is ApifyOperationDefinition {
return 'method' in value && 'path' in value;
}

function buildEndpointTree<T extends ApifyOperationTree>(
tree: T,
segments: string[] = [],
): ApifyEndpointTree<T> {
// Accumulator holds mixed endpoint fns / nested trees before the final cast.
const endpoints: Record<string, unknown> = {};

for (const [key, value] of Object.entries(tree)) {
if (isOperationDefinition(value)) {
const operationPath = [...segments, key].join('.');
endpoints[key] = async (
ctx: ApifyMcpContext,
input: ApifyOperationInput,
) => {
const response = await makeApifyRequest(value, ctx.key, input ?? {});
try {
await logEventFromContext(
ctx,
`apify.${operationPath}`,
{
method: value.method,
path: value.path,
},
'completed',
);
} catch {}
return response;
};
} else {
endpoints[key] = buildEndpointTree(value, [...segments, key]);
}
}

return endpoints as ApifyEndpointTree<T>;
}

function createOperationInputSchema(operation: ApifyOperationDefinition) {
const shape: Record<string, z.ZodTypeAny> = {
// Apify request bodies differ per endpoint and are passed through as-is.
body: z.unknown().optional(),
// Query values are endpoint-specific and not shared across operations.
query: z.record(z.string(), z.unknown()).optional(),
// Custom headers are forwarded without a fixed provider-wide shape.
headers: z.record(z.string(), z.unknown()).optional(),
contentType: z.string().optional(),
mediaType: z.string().optional(),
};

for (const param of operation.pathParams) {
shape[param] = z.union([z.string(), z.number()]);
}

for (const param of operation.queryParams ?? []) {
// Query metadata names the param but does not constrain its value type.
shape[param] = z.unknown().optional();
}

return z.object(shape).loose();
}

export function buildApifyEndpointSchemas<T extends ApifyOperationTree>(
tree: T,
segments: string[] = [],
): RequiredPluginEndpointSchemas<ApifyEndpointTree<T>> {
// Schema map is keyed by dotted path before the typed cast.
const schemas: Record<string, unknown> = {};

for (const [key, value] of Object.entries(tree)) {
if (isOperationDefinition(value)) {
schemas[[...segments, key].join('.')] = {
input: createOperationInputSchema(value),
output: ApifyOperationOutputSchema,
};
} else {
Object.assign(
schemas,
buildApifyEndpointSchemas(value, [...segments, key]),
);
}
}

return schemas as RequiredPluginEndpointSchemas<ApifyEndpointTree<T>>;
}

export function buildApifyEndpointMeta<T extends ApifyOperationTree>(
tree: T,
segments: string[] = [],
): RequiredPluginEndpointMeta<ApifyEndpointTree<T>> {
const meta: Record<string, EndpointMetaEntry> = {};

for (const [key, value] of Object.entries(tree)) {
if (isOperationDefinition(value)) {
meta[[...segments, key].join('.')] = {
riskLevel: value.riskLevel,
irreversible: value.irreversible,
description: value.description,
};
} else {
Object.assign(meta, buildApifyEndpointMeta(value, [...segments, key]));
}
}

return meta as RequiredPluginEndpointMeta<ApifyEndpointTree<T>>;
}

export const ApifyRestEndpoints = buildEndpointTree(apifyOperations);

export * from './operations';
58 changes: 42 additions & 16 deletions packages/apify/error-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,90 @@
import type { CorsairErrorHandler } from 'corsair/core';
import { ApiError } from 'corsair/http';
import { ApifyMcpAPIError } from './client';

function getStatus(error: Error): number | undefined {
if (error instanceof ApifyMcpAPIError) {
return error.status;
}
if (error instanceof ApiError) {
return error.status;
}
return undefined;
}

function getRetryAfter(error: Error): number | undefined {
if (error instanceof ApifyMcpAPIError) {
return error.retryAfter;
}
if (error instanceof ApiError) {
return error.retryAfter;
}
return undefined;
}

function messageOf(error: Error): string {
return error.message.toLowerCase();
}

export const errorHandlers = {
RATE_LIMIT_ERROR: {
match: (error: Error) => {
if (error instanceof ApifyMcpAPIError && getStatus(error) === 429) {
return true;
}
const msg = error.message.toLowerCase();
return msg.includes('429') || msg.includes('rate limit');
if (getStatus(error) === 429) return true;
const msg = messageOf(error);
return (
msg.includes('429') ||
msg.includes('rate limit') ||
msg.includes('rate-limit') ||
msg.includes('too many requests')
);
},
handler: async (error: Error) => ({
maxRetries: 3,
retryStrategy: 'exponential_backoff' as const,
retryStrategy: 'exponential_backoff_jitter' as const,
headersRetryAfterMs: getRetryAfter(error),
}),
},
AUTH_ERROR: {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
match: (error: Error) => {
if (error instanceof ApifyMcpAPIError && getStatus(error) === 401) {
return true;
}
const msg = error.message.toLowerCase();
if (getStatus(error) === 401) return true;
const msg = messageOf(error);
return (
msg.includes('unauthorized') ||
msg.includes('401') ||
msg.includes('authentication')
msg.includes('authentication') ||
msg.includes('token')
);
},
handler: async () => ({ maxRetries: 0 }),
},
PERMISSION_ERROR: {
match: (error: Error) => {
if (getStatus(error) === 403) return true;
const msg = messageOf(error);
return msg.includes('permission') || msg.includes('forbidden');
},
handler: async () => ({ maxRetries: 0 }),
},
NOT_FOUND_ERROR: {
match: (error: Error) => {
if (error instanceof ApifyMcpAPIError && getStatus(error) === 404) {
return true;
}
const msg = error.message.toLowerCase();
if (getStatus(error) === 404) return true;
const msg = messageOf(error);
return msg.includes('404') || msg.includes('not found');
},
handler: async () => ({ maxRetries: 0 }),
},
BAD_REQUEST_ERROR: {
match: (error: Error) => {
if (getStatus(error) === 400) return true;
return messageOf(error).includes('invalid');
},
handler: async () => ({ maxRetries: 0 }),
},
SERVER_ERROR: {
match: (error: Error) => {
const status = getStatus(error);
if (status !== undefined && status >= 500) return true;
const msg = error.message.toLowerCase();
const msg = messageOf(error);
return msg.includes('503') || msg.includes('server error');
},
handler: async () => ({
Expand Down
41 changes: 34 additions & 7 deletions packages/apify/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import type {
} from 'corsair/core';
import { AuthMissingError } from 'corsair/core';
import { ActorsEndpoints, DocsEndpoints, RunsEndpoints } from './endpoints';
import {
ApifyRestEndpoints,
apifyOperations,
buildApifyEndpointMeta,
buildApifyEndpointSchemas,
} from './endpoints/rest';
import type {
ApifyMcpEndpointInputs,
ApifyMcpEndpointOutputs,
Expand Down Expand Up @@ -65,11 +71,12 @@ const apifyEndpointsNested = {
actors: ActorsEndpoints,
runs: RunsEndpoints,
docs: DocsEndpoints,
...ApifyRestEndpoints,
} as const;

const apifyWebhooksNested = {} as const;

export const apifyEndpointSchemas = {
const mcpEndpointSchemas = {
'actors.searchActors': {
input: ApifyMcpEndpointInputSchemas.searchActors,
output: ApifyMcpEndpointOutputSchemas.searchActors,
Expand Down Expand Up @@ -102,11 +109,11 @@ export const apifyEndpointSchemas = {
input: ApifyMcpEndpointInputSchemas.fetchApifyDocs,
output: ApifyMcpEndpointOutputSchemas.fetchApifyDocs,
},
} as const satisfies RequiredPluginEndpointSchemas<typeof apifyEndpointsNested>;
} as const;

const defaultAuthType: AuthTypes = 'api_key' as const;

const apifyEndpointMeta = {
const mcpEndpointMeta = {
'actors.searchActors': {
riskLevel: 'read',
description: 'Search for Actors in the Apify Store',
Expand Down Expand Up @@ -142,6 +149,16 @@ const apifyEndpointMeta = {
riskLevel: 'read',
description: 'Fetch the full content of an Apify documentation page',
},
} as const;

export const apifyEndpointSchemas = {
...mcpEndpointSchemas,
...buildApifyEndpointSchemas(apifyOperations),
} as const satisfies RequiredPluginEndpointSchemas<typeof apifyEndpointsNested>;

const apifyEndpointMeta = {
...mcpEndpointMeta,
...buildApifyEndpointMeta(apifyOperations),
} as const satisfies RequiredPluginEndpointMeta<typeof apifyEndpointsNested>;

export const apifyAuthConfig = {
Expand Down Expand Up @@ -181,10 +198,14 @@ export function apify<const T extends ApifyMcpPluginOptions>(
endpointMeta: apifyEndpointMeta,
endpointSchemas: apifyEndpointSchemas,
pluginWebhookMatcher: undefined,
errorHandlers: {
...errorHandlers,
...options.errorHandlers,
},
errorHandlers: (() => {
const { DEFAULT: defaultHandler, ...specificDefaults } = errorHandlers;
return {
...specificDefaults,
...(options.errorHandlers || {}),
DEFAULT: options.errorHandlers?.DEFAULT || defaultHandler,
};
})(),
keyBuilder: async (ctx: ApifyMcpKeyBuilderContext, source) => {
if (source === 'endpoint' && options.key) {
return options.key;
Expand All @@ -203,6 +224,12 @@ export function apify<const T extends ApifyMcpPluginOptions>(
} satisfies InternalApifyMcpPlugin;
}

export type {
ApifyEndpointInputs,
ApifyEndpointOutputs,
ApifyOperationInput,
ApifyOperationOutput,
} from './endpoints/rest-types';
export type {
ApifyMcpEndpointInputs,
ApifyMcpEndpointOutputs,
Expand Down
Loading
Loading