Skip to content

Commit 84e9a23

Browse files
feat(mcp): support dynamically discovering and invoking tools for APIs with many endpoints
1 parent c90857a commit 84e9a23

File tree

12 files changed

+527
-195
lines changed

12 files changed

+527
-195
lines changed

packages/mcp-server/README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ You can run the MCP Server directly via `npx`:
1010

1111
```sh
1212
export HANZO_API_KEY="My API Key"
13-
npx -y hanzoai-mcp
13+
npx -y hanzoai-mcp@latest
1414
```
1515

1616
### Via MCP Client
@@ -25,7 +25,7 @@ For clients with a configuration JSON, it might look something like this:
2525
"mcpServers": {
2626
"hanzoai_api": {
2727
"command": "npx",
28-
"args": ["-y", "hanzoai-mcp", "--client=claude"],
28+
"args": ["-y", "hanzoai-mcp", "--client=claude", "--tools=dynamic"],
2929
"env": {
3030
"HANZO_API_KEY": "My API Key"
3131
}
@@ -34,7 +34,14 @@ For clients with a configuration JSON, it might look something like this:
3434
}
3535
```
3636

37-
## Filtering tools
37+
## Exposing endpoints to your MCP Client
38+
39+
There are two ways to expose endpoints as tools in the MCP server:
40+
41+
1. Exposing one tool per endpoint, and filtering as necessary
42+
2. Exposing a set of tools to dynamically discover and invoke endpoints from the API
43+
44+
### Filtering endpoints and tools
3845

3946
You can run the package on the command line to discover and filter the set of tools that are exposed by the
4047
MCP Server. This can be helpful for large APIs where including all endpoints at once is too much for your AI's
@@ -46,6 +53,21 @@ You can filter by multiple aspects:
4653
- `--resource` includes all tools under a specific resource, and can have wildcards, e.g. `my.resource*`
4754
- `--operation` includes just read (get/list) or just write operations
4855

56+
### Dynamic tools
57+
58+
If you specify `--tools=dynamic` to the MCP server, instead of exposing one tool per endpoint in the API, it will
59+
expose the following tools:
60+
61+
1. `list_api_endpoints` - Discovers available endpoints, with optional filtering by search query
62+
2. `get_api_endpoint_schema` - Gets detailed schema information for a specific endpoint
63+
3. `invoke_api_endpoint` - Executes any endpoint with the appropriate parameters
64+
65+
This allows you to have the full set of API endpoints available to your MCP Client, while not requiring that all
66+
of their schemas be loaded into context at once. Instead, the LLM will automatically use these tools together to
67+
search for, look up, and invoke endpoints dynamically. However, due to the indirect nature of the schemas, it
68+
can struggle to provide the correct properties a bit more than when tools are imported explicitly. Therefore,
69+
you can opt-in to explicit tools, the dynamic tools, or both.
70+
4971
See more information with `--help`.
5072

5173
All of these command-line options can be repeated, combined together, and have corresponding exclusion versions (e.g. `--no-tool`).

packages/mcp-server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"dependencies": {
3030
"hanzoai": "file:../../dist/",
3131
"@modelcontextprotocol/sdk": "^1.6.1",
32-
"yargs": "^17.7.2"
32+
"yargs": "^17.7.2",
33+
"@cloudflare/cabidela": "^0.2.4"
3334
},
3435
"bin": {
3536
"mcp-server": "dist/index.js"
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import Hanzo from 'hanzoai';
2+
import { Endpoint } from './tools';
3+
import { zodToJsonSchema } from 'zod-to-json-schema';
4+
import { z } from 'zod';
5+
import { Cabidela } from '@cloudflare/cabidela';
6+
7+
function zodToInputSchema(schema: z.ZodSchema) {
8+
return {
9+
type: 'object' as const,
10+
...(zodToJsonSchema(schema) as any),
11+
};
12+
}
13+
14+
/**
15+
* A list of tools that expose all the endpoints in the API dynamically.
16+
*
17+
* Instead of exposing every endpoint as it's own tool, which uses up too many tokens for LLMs to use at once,
18+
* we expose a single tool that can be used to search for endpoints by name, resource, operation, or tag, and then
19+
* a generic endpoint that can be used to invoke any endpoint with the provided arguments.
20+
*
21+
* @param endpoints - The endpoints to include in the list.
22+
*/
23+
export function dynamicTools(endpoints: Endpoint[]): Endpoint[] {
24+
const listEndpointsSchema = z.object({
25+
search_query: z
26+
.string()
27+
.optional()
28+
.describe(
29+
'An optional search query to filter the endpoints by. Provide a partial name, resource, operation, or tag to filter the endpoints returned.',
30+
),
31+
});
32+
33+
const listEndpointsTool = {
34+
metadata: {
35+
resource: 'dynamic_tools',
36+
operation: 'read' as const,
37+
tags: [],
38+
},
39+
tool: {
40+
name: 'list_api_endpoints',
41+
description: 'List or search for all endpoints in the Hanzo TypeScript API',
42+
inputSchema: zodToInputSchema(listEndpointsSchema),
43+
},
44+
handler: async (client: Hanzo, args: Record<string, unknown> | undefined) => {
45+
const query = args && listEndpointsSchema.parse(args).search_query?.trim();
46+
47+
const filteredEndpoints =
48+
query && query.length > 0 ?
49+
endpoints.filter((endpoint) => {
50+
const fieldsToMatch = [
51+
endpoint.tool.name,
52+
endpoint.metadata.resource,
53+
endpoint.metadata.operation,
54+
...endpoint.metadata.tags,
55+
];
56+
return fieldsToMatch.some((field) => field.toLowerCase().includes(query.toLowerCase()));
57+
})
58+
: endpoints;
59+
60+
return {
61+
tools: filteredEndpoints.map(({ tool, metadata }) => ({
62+
name: tool.name,
63+
description: tool.description,
64+
resource: metadata.resource,
65+
operation: metadata.operation,
66+
tags: metadata.tags,
67+
})),
68+
};
69+
},
70+
};
71+
72+
const getEndpointSchema = z.object({
73+
endpoint: z.string().describe('The name of the endpoint to get the schema for.'),
74+
});
75+
const getEndpointTool = {
76+
metadata: {
77+
resource: 'dynamic_tools',
78+
operation: 'read' as const,
79+
tags: [],
80+
},
81+
tool: {
82+
name: 'get_api_endpoint_schema',
83+
description:
84+
'Get the schema for an endpoint in the Hanzo TypeScript API. You can use the schema returned by this tool to invoke an endpoint with the `invoke_api_endpoint` tool.',
85+
inputSchema: zodToInputSchema(getEndpointSchema),
86+
},
87+
handler: async (client: Hanzo, args: Record<string, unknown> | undefined) => {
88+
if (!args) {
89+
throw new Error('No endpoint provided');
90+
}
91+
const endpointName = getEndpointSchema.parse(args).endpoint;
92+
93+
const endpoint = endpoints.find((e) => e.tool.name === endpointName);
94+
if (!endpoint) {
95+
throw new Error(`Endpoint ${endpointName} not found`);
96+
}
97+
return endpoint.tool;
98+
},
99+
};
100+
101+
const invokeEndpointSchema = z.object({
102+
endpoint_name: z.string().describe('The name of the endpoint to invoke.'),
103+
args: z
104+
.record(z.string(), z.any())
105+
.describe(
106+
'The arguments to pass to the endpoint. This must match the schema returned by the `get_api_endpoint_schema` tool.',
107+
),
108+
});
109+
110+
const invokeEndpointTool = {
111+
metadata: {
112+
resource: 'dynamic_tools',
113+
operation: 'write' as const,
114+
tags: [],
115+
},
116+
tool: {
117+
name: 'invoke_api_endpoint',
118+
description:
119+
'Invoke an endpoint in the Hanzo TypeScript API. Note: use the `list_api_endpoints` tool to get the list of endpoints and `get_api_endpoint_schema` tool to get the schema for an endpoint.',
120+
inputSchema: zodToInputSchema(invokeEndpointSchema),
121+
},
122+
handler: async (client: Hanzo, args: Record<string, unknown> | undefined) => {
123+
if (!args) {
124+
throw new Error('No endpoint provided');
125+
}
126+
const { success, data, error } = invokeEndpointSchema.safeParse(args);
127+
if (!success) {
128+
throw new Error(`Invalid arguments for endpoint. ${error?.format()}`);
129+
}
130+
const { endpoint_name, args: endpointArgs } = data;
131+
132+
const endpoint = endpoints.find((e) => e.tool.name === endpoint_name);
133+
if (!endpoint) {
134+
throw new Error(
135+
`Endpoint ${endpoint_name} not found. Use the \`list_api_endpoints\` tool to get the list of available endpoints.`,
136+
);
137+
}
138+
139+
try {
140+
// Try to validate the arguments for a better error message
141+
const cabidela = new Cabidela(endpoint.tool.inputSchema, { fullErrors: true });
142+
cabidela.validate(endpointArgs);
143+
} catch (error) {
144+
throw new Error(`Invalid arguments for endpoint ${endpoint_name}:\n${error}`);
145+
}
146+
147+
return endpoint.handler(client, endpointArgs);
148+
},
149+
};
150+
151+
return [getEndpointTool, listEndpointsTool, invokeEndpointTool];
152+
}

packages/mcp-server/src/index.ts

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,24 @@
11
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2-
import { init, server } from './server';
3-
import { Endpoint, endpoints, Filter, query } from './tools';
4-
import { applyCompatibilityTransformations } from './compat';
5-
import { parseOptions } from './options';
2+
import { init, selectTools, server } from './server';
3+
import { Endpoint, endpoints } from './tools';
4+
import { ParsedOptions, parseOptions } from './options';
65

76
async function main() {
8-
const { filters, capabilities, list } = parseOptionsOrError();
7+
const options = parseOptionsOrError();
98

10-
if (list) {
9+
if (options.list) {
1110
listAllTools();
1211
return;
1312
}
1413

15-
const filteredEndpoints = filterEndpointsOrError(filters, endpoints);
16-
17-
// Apply compatibility transformations
18-
const transformedEndpoints = applyCompatibilityTransformations(filteredEndpoints, capabilities);
14+
const includedTools = selectToolsOrError(endpoints, options);
1915

2016
console.error(
21-
`MCP Server starting with ${transformedEndpoints.length} tools:`,
22-
transformedEndpoints.map((e) => e.tool.name),
17+
`MCP Server starting with ${includedTools.length} tools:`,
18+
includedTools.map((e) => e.tool.name),
2319
);
2420

25-
init({ server, endpoints: transformedEndpoints });
21+
init({ server, endpoints: includedTools });
2622

2723
const transport = new StdioServerTransport();
2824
await server.connect(transport);
@@ -45,14 +41,14 @@ function parseOptionsOrError() {
4541
}
4642
}
4743

48-
function filterEndpointsOrError(filters: Filter[], endpoints: Endpoint[]) {
44+
function selectToolsOrError(endpoints: Endpoint[], options: ParsedOptions) {
4945
try {
50-
const filteredEndpoints = query(filters, endpoints);
51-
if (filteredEndpoints.length === 0) {
46+
const includedTools = selectTools(endpoints, options);
47+
if (includedTools.length === 0) {
5248
console.error('No tools match the provided filters.');
5349
process.exit(1);
5450
}
55-
return filteredEndpoints;
51+
return includedTools;
5652
} catch (error) {
5753
if (error instanceof Error) {
5854
console.error('Error filtering tools:', error.message);
@@ -65,10 +61,10 @@ function filterEndpointsOrError(filters: Filter[], endpoints: Endpoint[]) {
6561

6662
function listAllTools() {
6763
if (endpoints.length === 0) {
68-
console.error('No tools available.');
64+
console.log('No tools available.');
6965
return;
7066
}
71-
console.error('Available tools:\n');
67+
console.log('Available tools:\n');
7268

7369
// Group endpoints by resource
7470
const resourceGroups = new Map<string, typeof endpoints>();
@@ -86,7 +82,7 @@ function listAllTools() {
8682

8783
// Display hierarchically by resource
8884
for (const resource of sortedResources) {
89-
console.error(`Resource: ${resource}`);
85+
console.log(`Resource: ${resource}`);
9086

9187
const resourceEndpoints = resourceGroups.get(resource)!;
9288
// Sort endpoints by tool name
@@ -98,9 +94,9 @@ function listAllTools() {
9894
metadata: { operation, tags },
9995
} = endpoint;
10096

101-
console.error(` - ${tool.name} (${operation}) ${tags.length > 0 ? `tags: ${tags.join(', ')}` : ''}`);
102-
console.error(` Description: ${tool.description}`);
97+
console.log(` - ${tool.name} (${operation}) ${tags.length > 0 ? `tags: ${tags.join(', ')}` : ''}`);
98+
console.log(` Description: ${tool.description}`);
10399
}
104-
console.error('');
100+
console.log('');
105101
}
106102
}

packages/mcp-server/src/options.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const CLIENT_PRESETS: Record<ClientType, ClientCapabilities> = {
4444
};
4545

4646
export interface ParsedOptions {
47+
includeDynamicTools: boolean | undefined;
48+
includeAllTools: boolean | undefined;
4749
filters: Filter[];
4850
capabilities: ClientCapabilities;
4951
list: boolean;
@@ -80,6 +82,18 @@ function parseCapabilityValue(cap: string): { name: Capability; value?: number }
8082

8183
export function parseOptions(): ParsedOptions {
8284
const opts = yargs(hideBin(process.argv))
85+
.option('tools', {
86+
type: 'string',
87+
array: true,
88+
choices: ['dynamic', 'all'],
89+
description: 'Use dynamic tools or all tools',
90+
})
91+
.option('no-tools', {
92+
type: 'string',
93+
array: true,
94+
choices: ['dynamic', 'all'],
95+
description: 'Do not use any dynamic or all tools',
96+
})
8397
.option('tool', {
8498
type: 'string',
8599
array: true,
@@ -262,7 +276,15 @@ export function parseOptions(): ParsedOptions {
262276
}
263277
}
264278

279+
const explicitTools = Boolean(argv.tools || argv.noTools);
280+
const includeDynamicTools =
281+
explicitTools ? argv.tools?.includes('dynamic') && !argv.noTools?.includes('dynamic') : undefined;
282+
const includeAllTools =
283+
explicitTools ? argv.tools?.includes('all') && !argv.noTools?.includes('all') : undefined;
284+
265285
return {
286+
includeDynamicTools,
287+
includeAllTools,
266288
filters,
267289
capabilities: clientCapabilities,
268290
list: argv.list || false,

0 commit comments

Comments
 (0)