Skip to content

Commit fb17b1e

Browse files
MQ37jirispilka
andauthored
feat: structured output for the crucial tools (#349)
* feat: add structured output to the crucial tools * refactor: extract shared output schemas to reduce duplication Extract duplicate JSON schema definitions from fetch-actor-details and store-search tools into a centralized src/tools/schemas.ts module. This eliminates ~170 lines of duplicated code while making schemas easier to maintain and reuse across tools. Schemas extracted: - developerSchema - pricingSchema - statsSchema - actorInfoSchema (reused by both tools) - actorDetailsOutputSchema - actorSearchOutputSchema * fix: remove incorrect 1000x multiplication in pricing values Remove the multiplication by 1000 in pricingInfoToStructured for PRICE_PER_DATASET_ITEM model. The pricePerUnit field now correctly represents the actual price per unit, making the naming consistent and accurate across all pricing models. Also add JSDoc clarifying that the function transforms API response to match unstructured text output format for consistency. * refactor: extract test validation logic to reduce duplication across structured output tests * fix typo * fix: links in README.md * Update src/tools/schemas.ts Co-authored-by: Jiří Spilka <[email protected]> * Update src/tools/schemas.ts Co-authored-by: Jiří Spilka <[email protected]> * Update src/tools/schemas.ts Co-authored-by: Jiří Spilka <[email protected]> * Update src/tools/schemas.ts Co-authored-by: Jiří Spilka <[email protected]> * Update src/tools/schemas.ts Co-authored-by: Jiří Spilka <[email protected]> * Update src/tools/schemas.ts Co-authored-by: Jiří Spilka <[email protected]> * Update src/tools/schemas.ts Co-authored-by: Jiří Spilka <[email protected]> * address review comments * fix build --------- Co-authored-by: Jiri Spilka <[email protected]> Co-authored-by: Jiří Spilka <[email protected]>
1 parent f3a23a2 commit fb17b1e

18 files changed

+680
-55
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ The Apify Model Context Protocol (MCP) server at [**mcp.apify.com**](https://mcp
3131
## Table of Contents
3232
- [🌐 Introducing the Apify MCP server](#-introducing-the-apify-mcp-server)
3333
- [🚀 Quickstart](#-quickstart)
34-
- [🤖 MCP clients and examples](#-mcp-clients-and-examples)
34+
- [🤖 MCP clients](#-mcp-clients)
3535
- [🪄 Try Apify MCP instantly](#-try-apify-mcp-instantly)
3636
- [🛠️ Tools, resources, and prompts](#-tools-resources-and-prompts)
37-
- [📊 Telemetry](#telemetry)
37+
- [📊 Telemetry](#-telemetry)
3838
- [🐛 Troubleshooting (local MCP server)](#-troubleshooting-local-mcp-server)
3939
- [⚙️ Development](#-development)
4040
- [🤝 Contributing](#-contributing)

src/tools/actor.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ Step 2: Call Actor (step="call")
373373
EXAMPLES:
374374
- user_input: Get instagram posts using apify/instagram-scraper`,
375375
inputSchema: zodToJsonSchema(callActorArgs) as ToolInputSchema,
376+
// For now we are not adding the strucuted output schema since this tool is quite complex and has multiple possible ends states
376377
ajvValidate: ajv.compile({
377378
...zodToJsonSchema(callActorArgs),
378379
// Additional props true to allow skyfire-pay-id
@@ -445,13 +446,11 @@ EXAMPLES:
445446
// Regular Actor: return schema
446447
const details = await fetchActorDetails(apifyClientForDefinition, baseActorName);
447448
if (!details) {
448-
return buildMCPResponse({
449-
texts: [`Actor information for '${baseActorName}' was not found.
449+
return buildMCPResponse({ texts: [`Actor information for '${baseActorName}' was not found.
450450
Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists.
451451
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`],
452-
isError: true,
453-
toolStatus: TOOL_STATUS.SOFT_FAIL,
454-
});
452+
isError: true,
453+
toolStatus: TOOL_STATUS.SOFT_FAIL });
455454
}
456455
const content = [
457456
`Actor name: ${actorName}`,
@@ -541,13 +540,11 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
541540
const [actor] = await getActorsAsTools([actorName], apifyClient);
542541

543542
if (!actor) {
544-
return buildMCPResponse({
545-
texts: [`Actor '${actorName}' was not found.
543+
return buildMCPResponse({ texts: [`Actor '${actorName}' was not found.
546544
Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists.
547545
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`],
548-
isError: true,
549-
toolStatus: TOOL_STATUS.SOFT_FAIL,
550-
});
546+
isError: true,
547+
toolStatus: TOOL_STATUS.SOFT_FAIL });
551548
}
552549

553550
if (!actor.ajvValidate(input)) {
@@ -583,12 +580,10 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
583580
} catch (error) {
584581
logHttpError(error, 'Failed to call Actor', { actorName, performStep });
585582
// Let the server classify the error; we only mark it as an MCP error response
586-
return buildMCPResponse({
587-
texts: [`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}.
583+
return buildMCPResponse({ texts: [`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}.
588584
Please verify the Actor name, input parameters, and ensure the Actor exists.
589585
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}, or get Actor details using: ${HelperTools.ACTOR_GET_DETAILS}.`],
590-
isError: true,
591-
});
586+
isError: true });
592587
}
593588
},
594589
};

src/tools/fetch-actor-details.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js';
77
import { fetchActorDetails } from '../utils/actor-details.js';
88
import { ajv } from '../utils/ajv.js';
99
import { buildMCPResponse } from '../utils/mcp.js';
10+
import { actorDetailsOutputSchema } from './structured-output-schemas.js';
1011

1112
const fetchActorDetailsToolArgsSchema = z.object({
1213
actor: z.string()
@@ -29,6 +30,7 @@ USAGE EXAMPLES:
2930
- user_input: What is the input schema for apify/rag-web-browser?
3031
- user_input: What is the pricing for apify/instagram-scraper?`,
3132
inputSchema: zodToJsonSchema(fetchActorDetailsToolArgsSchema) as ToolInputSchema,
33+
outputSchema: actorDetailsOutputSchema,
3234
ajvValidate: ajv.compile(zodToJsonSchema(fetchActorDetailsToolArgsSchema)),
3335
annotations: {
3436
title: 'Fetch Actor details',
@@ -65,6 +67,11 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
6567
}
6668
// Return the actor card, README, and input schema (if it has non-empty properties) as separate text blocks
6769
// This allows better formatting in the final output
68-
return buildMCPResponse({ texts });
70+
const structuredContent = {
71+
actorInfo: details.actorCardStructured,
72+
readme: details.readme,
73+
inputSchema: details.inputSchema,
74+
};
75+
return buildMCPResponse({ texts, structuredContent });
6976
},
7077
} as const;

src/tools/fetch-apify-docs.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ajv } from '../utils/ajv.js';
88
import { htmlToMarkdown } from '../utils/html-to-md.js';
99
import { logHttpError } from '../utils/logging.js';
1010
import { buildMCPResponse } from '../utils/mcp.js';
11+
import { fetchApifyDocsToolOutputSchema } from './structured-output-schemas.js';
1112

1213
const fetchApifyDocsToolArgsSchema = z.object({
1314
url: z.string()
@@ -28,6 +29,7 @@ USAGE EXAMPLES:
2829
- user_input: Fetch https://docs.apify.com/platform/actors/running#builds
2930
- user_input: Fetch https://docs.apify.com/academy`,
3031
inputSchema: zodToJsonSchema(fetchApifyDocsToolArgsSchema) as ToolInputSchema,
32+
outputSchema: fetchApifyDocsToolOutputSchema,
3133
ajvValidate: ajv.compile(zodToJsonSchema(fetchApifyDocsToolArgsSchema)),
3234
annotations: {
3335
title: 'Fetch Apify docs',
@@ -43,13 +45,11 @@ USAGE EXAMPLES:
4345

4446
// Only allow URLs starting with https://docs.apify.com
4547
if (!url.startsWith('https://docs.apify.com')) {
46-
return buildMCPResponse({
47-
texts: [`Invalid URL: "${url}".
48+
return buildMCPResponse({ texts: [`Invalid URL: "${url}".
4849
Only URLs starting with "https://docs.apify.com" are allowed.
4950
Please provide a valid Apify documentation URL. You can find documentation URLs using the ${HelperTools.DOCS_SEARCH} tool.`],
50-
isError: true,
51-
toolStatus: TOOL_STATUS.SOFT_FAIL,
52-
});
51+
isError: true,
52+
toolStatus: TOOL_STATUS.SOFT_FAIL });
5353
}
5454

5555
// Cache URL without fragment to avoid fetching the same page multiple times
@@ -91,6 +91,6 @@ Please verify the URL is correct and accessible. You can search for available do
9191
}
9292
}
9393

94-
return buildMCPResponse({ texts: [`Fetched content from ${url}:\n\n${markdown}`] });
94+
return buildMCPResponse({ texts: [`Fetched content from ${url}:\n\n${markdown}`], structuredContent: { url, content: markdown } });
9595
},
9696
} as const;

src/tools/run.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,9 @@ USAGE EXAMPLES:
4848
const client = new ApifyClient({ token: apifyToken });
4949
const v = await client.run(parsed.runId).get();
5050
if (!v) {
51-
return buildMCPResponse({
52-
texts: [`Run with ID '${parsed.runId}' not found.`],
51+
return buildMCPResponse({ texts: [`Run with ID '${parsed.runId}' not found.`],
5352
isError: true,
54-
toolStatus: TOOL_STATUS.SOFT_FAIL,
55-
});
53+
toolStatus: TOOL_STATUS.SOFT_FAIL });
5654
}
5755
const texts = [`\`\`\`json\n${JSON.stringify(v, null, 2)}\n\`\`\``];
5856
return buildMCPResponse({ texts });
@@ -84,6 +82,7 @@ USAGE EXAMPLES:
8482
- user_input: Show last 20 lines of logs for run y2h7sK3Wc
8583
- user_input: Get logs for run y2h7sK3Wc`,
8684
inputSchema: zodToJsonSchema(GetRunLogArgs) as ToolInputSchema,
85+
// It does not make sense to add structured output here since the log API just returns plain text
8786
ajvValidate: ajv.compile(zodToJsonSchema(GetRunLogArgs)),
8887
annotations: {
8988
title: 'Get Actor run log',

src/tools/run_collection.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ApifyClient } from '../apify-client.js';
55
import { HelperTools } from '../const.js';
66
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js';
77
import { ajv } from '../utils/ajv.js';
8+
import { buildMCPResponse } from '../utils/mcp.js';
89

910
const getUserRunsListArgs = z.object({
1011
offset: z.number()
@@ -50,6 +51,8 @@ USAGE EXAMPLES:
5051
const parsed = getUserRunsListArgs.parse(args);
5152
const client = new ApifyClient({ token: apifyToken });
5253
const runs = await client.runs().list({ limit: parsed.limit, offset: parsed.offset, desc: parsed.desc, status: parsed.status });
53-
return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(runs)}\n\`\`\`` }] };
54+
return buildMCPResponse({
55+
texts: [`\`\`\`json\n${JSON.stringify(runs)}\n\`\`\``],
56+
});
5457
},
5558
} as const;

src/tools/search-apify-docs.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js';
66
import { ajv } from '../utils/ajv.js';
77
import { searchApifyDocsCached } from '../utils/apify-docs.js';
88
import { buildMCPResponse } from '../utils/mcp.js';
9+
import { searchApifyDocsToolOutputSchema } from './structured-output-schemas.js';
910

1011
const searchApifyDocsToolArgsSchema = z.object({
1112
query: z.string()
@@ -50,6 +51,7 @@ USAGE EXAMPLES:
5051
- query: How to define Actor input schema?
5152
- query: How scrape with Crawlee?`,
5253
inputSchema: zodToJsonSchema(searchApifyDocsToolArgsSchema) as ToolInputSchema,
54+
outputSchema: searchApifyDocsToolOutputSchema,
5355
ajvValidate: ajv.compile(zodToJsonSchema(searchApifyDocsToolArgsSchema)),
5456
annotations: {
5557
title: 'Search Apify docs',
@@ -66,18 +68,23 @@ USAGE EXAMPLES:
6668
const results = resultsRaw.slice(parsed.offset, parsed.offset + parsed.limit);
6769

6870
if (results.length === 0) {
69-
return buildMCPResponse({
70-
texts: [`No results found for the query "${query}" with limit ${parsed.limit} and offset ${parsed.offset}.
71+
return buildMCPResponse({ texts: [`No results found for the query "${query}" with limit ${parsed.limit} and offset ${parsed.offset}.
7172
Please try a different query with different keywords, or adjust the limit and offset parameters.
72-
You can also try using more specific or alternative keywords related to your search topic.`],
73-
});
73+
You can also try using more specific or alternative keywords related to your search topic.`] });
7474
}
7575

7676
const textContent = `You can use the Apify docs fetch tool to retrieve the full content of a document by its URL. The document fragment refers to the section of the content containing the relevant part for the search result item.
7777
Search results for "${query}":
7878
7979
${results.map((result) => `- Document URL: ${result.url}${result.fragment ? `\n Document fragment: ${result.fragment}` : ''}
80-
Content: ${result.content}`).join('\n\n')}`;
81-
return buildMCPResponse({ texts: [textContent] });
80+
Content: ${result.content}`).join('\n\n')}`;
81+
const structuredContent = {
82+
results: results.map((result) => ({
83+
url: result.url,
84+
fragment: result.fragment,
85+
content: result.content,
86+
})),
87+
};
88+
return buildMCPResponse({ texts: [textContent], structuredContent });
8289
},
8390
} as const;

src/tools/store_collection.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import zodToJsonSchema from 'zod-to-json-schema';
55
import { ApifyClient } from '../apify-client.js';
66
import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js';
77
import type { ActorPricingModel, ExtendedActorStoreList, InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js';
8-
import { formatActorToActorCard } from '../utils/actor-card.js';
8+
import { formatActorToActorCard, formatActorToStructuredCard } from '../utils/actor-card.js';
99
import { ajv } from '../utils/ajv.js';
1010
import { buildMCPResponse } from '../utils/mcp.js';
11+
import { actorSearchOutputSchema } from './structured-output-schemas.js';
1112

1213
export async function searchActorsByKeywords(
1314
search: string,
@@ -124,6 +125,7 @@ Returns list of Actor cards with the following info:
124125
- **Rating:** Out of 5 (if available)
125126
`,
126127
inputSchema: zodToJsonSchema(searchActorsArgsSchema) as ToolInputSchema,
128+
outputSchema: actorSearchOutputSchema,
127129
ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)),
128130
annotations: {
129131
title: 'Search Actors',
@@ -144,25 +146,34 @@ Returns list of Actor cards with the following info:
144146
const actorCards = actors.length === 0 ? [] : actors.map(formatActorToActorCard);
145147

146148
if (actorCards.length === 0) {
147-
return buildMCPResponse({
148-
texts: [`No Actors were found for the search query "${parsed.keywords}".
149-
Please try different keywords or simplify your query. Consider using more specific platform names (e.g., "Instagram", "Twitter") and data types (e.g., "posts", "products") rather than generic terms like "scraper" or "crawler".`],
150-
});
149+
return buildMCPResponse({ texts: [`No Actors were found for the search query "${parsed.keywords}".
150+
Please try different keywords or simplify your query. Consider using more specific platform names (e.g., "Instagram", "Twitter") and data types (e.g., "posts", "products") rather than generic terms like "scraper" or "crawler".`] });
151151
}
152152

153153
const actorsText = actorCards.join('\n\n');
154154

155-
return buildMCPResponse({ texts: [`
156-
# Search results:
157-
- **Search query:** ${parsed.keywords}
158-
- **Number of Actors found:** ${actorCards.length}
155+
// Generate structured cards for the actors
156+
const structuredActorCards = actors.map(formatActorToStructuredCard);
159157

160-
# Actors:
158+
const texts = [`
159+
# Search results:
160+
- **Search query:** ${parsed.keywords}
161+
- **Number of Actors found:** ${actorCards.length}
161162
162-
${actorsText}
163+
# Actors:
163164
164-
If you need more detailed information about any of these Actors, including their input schemas and usage instructions, please use the ${HelperTools.ACTOR_GET_DETAILS} tool with the specific Actor name.
165-
If the search did not return relevant results, consider refining your keywords, use broader terms or removing less important words from the keywords.
166-
`] });
165+
${actorsText}
166+
167+
If you need more detailed information about any of these Actors, including their input schemas and usage instructions, please use the ${HelperTools.ACTOR_GET_DETAILS} tool with the specific Actor name.
168+
If the search did not return relevant results, consider refining your keywords, use broader terms or removing less important words from the keywords.
169+
`];
170+
171+
const structuredContent = {
172+
actors: structuredActorCards,
173+
query: parsed.keywords,
174+
count: actorCards.length,
175+
};
176+
177+
return buildMCPResponse({ texts, structuredContent });
167178
},
168179
} as const;

0 commit comments

Comments
 (0)