Skip to content

Commit 0ea4b4a

Browse files
authored
feat(codegen): derive idempotentHint + openWorldHint, allow per-action annotation overrides (#69)
1 parent 4294c28 commit 0ea4b4a

5 files changed

Lines changed: 400 additions & 32 deletions

File tree

.changeset/wild-hints-complete.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@taskade/mcp-openapi-codegen': patch
3+
'@taskade/mcp-server': patch
4+
---
5+
6+
Generated tools now carry the full MCP annotation set: derived idempotentHint and openWorldHint:false alongside readOnly/destructive hints; per-action overrides supported.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { OpenAPIV3 } from 'openapi-types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { codegen } from './codegen';
5+
6+
const document = {
7+
openapi: '3.0.0',
8+
info: { title: 'Tiny API', version: '1.0.0' },
9+
paths: {
10+
'/things': {
11+
get: { operationId: 'thingList', description: 'List things', responses: {} },
12+
post: { operationId: 'thingCreate', description: 'Create a thing', responses: {} },
13+
},
14+
'/things/{thingId}': {
15+
put: { operationId: 'thingUpdate', description: 'Update a thing', responses: {} },
16+
delete: { operationId: 'thingDelete', description: 'Delete a thing', responses: {} },
17+
},
18+
},
19+
} as OpenAPIV3.Document;
20+
21+
/**
22+
* Extracts the annotation hints for a named tool out of the generated
23+
* (prettier-formatted) source.
24+
*/
25+
const hintsFor = (output: string, toolName: string) => {
26+
const chunk = output
27+
.split('server.tool(')
28+
.find((part) => part.trimStart().startsWith(`"${toolName}"`));
29+
expect(chunk, `generated output contains tool ${toolName}`).toBeDefined();
30+
31+
const hints: Record<string, boolean> = {};
32+
for (const key of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) {
33+
const match = chunk?.match(new RegExp(`${key}:\\s*(true|false)`));
34+
expect(match, `${toolName} carries ${key}`).toBeTruthy();
35+
hints[key] = match?.[1] === 'true';
36+
}
37+
return hints;
38+
};
39+
40+
describe('codegen derived annotations', () => {
41+
it('derives the full MCP hint set from the HTTP method', async () => {
42+
const output = await codegen({ document });
43+
44+
expect(hintsFor(output, 'thingList')).toEqual({
45+
readOnlyHint: true,
46+
destructiveHint: false,
47+
idempotentHint: true,
48+
openWorldHint: false,
49+
});
50+
expect(hintsFor(output, 'thingCreate')).toEqual({
51+
readOnlyHint: false,
52+
destructiveHint: false,
53+
idempotentHint: false,
54+
openWorldHint: false,
55+
});
56+
expect(hintsFor(output, 'thingUpdate')).toEqual({
57+
readOnlyHint: false,
58+
destructiveHint: false,
59+
idempotentHint: true,
60+
openWorldHint: false,
61+
});
62+
expect(hintsFor(output, 'thingDelete')).toEqual({
63+
readOnlyHint: false,
64+
destructiveHint: true,
65+
idempotentHint: true,
66+
openWorldHint: false,
67+
});
68+
});
69+
70+
it('lets a per-action config override any derived hint, including to false', async () => {
71+
const output = await codegen({
72+
document,
73+
actions: {
74+
// A POST that is actually idempotent (e.g. taskComplete-style actions).
75+
thingCreate: { idempotentHint: true },
76+
// An explicit `false` override must beat a derived `true`.
77+
thingDelete: { destructiveHint: false },
78+
},
79+
});
80+
81+
expect(hintsFor(output, 'thingCreate')).toEqual({
82+
readOnlyHint: false,
83+
destructiveHint: false,
84+
idempotentHint: true,
85+
openWorldHint: false,
86+
});
87+
expect(hintsFor(output, 'thingDelete')).toEqual({
88+
readOnlyHint: false,
89+
destructiveHint: false,
90+
idempotentHint: true,
91+
openWorldHint: false,
92+
});
93+
});
94+
});

packages/openapi-codegen/src/codegen.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,24 @@ type ActionConfig = Partial<{
1212
title: string;
1313
description: string;
1414
normalizer: (args: any) => any;
15+
/**
16+
* Explicit MCP annotation overrides. When set, they beat the values derived
17+
* from the HTTP method (e.g. a POST action that is actually idempotent can
18+
* set `idempotentHint: true`).
19+
*/
20+
readOnlyHint: boolean;
21+
destructiveHint: boolean;
22+
idempotentHint: boolean;
23+
openWorldHint: boolean;
1524
}>;
1625

26+
const ANNOTATION_HINT_KEYS = [
27+
'readOnlyHint',
28+
'destructiveHint',
29+
'idempotentHint',
30+
'openWorldHint',
31+
] as const;
32+
1733
const isActionsEnabled = (isActionsEnabledOpt: IsActionsEnabledOpt, actionName: string) => {
1834
if (typeof isActionsEnabledOpt === 'function') {
1935
return isActionsEnabledOpt(actionName);
@@ -81,23 +97,39 @@ export const codegen = async (opts: CodegenOpts) => {
8197
}
8298

8399
// Derive MCP tool annotations from the HTTP method: GET/HEAD are read-only,
84-
// DELETE is destructive. A human-friendly title can be supplied via opts.actions.
100+
// DELETE is destructive, GET/HEAD/PUT/DELETE are idempotent per HTTP
101+
// semantics (POST is not). openWorldHint is always false: this server only
102+
// talks to the Taskade API — a closed world. A human-friendly title and
103+
// explicit hint overrides can be supplied via opts.actions.
85104
const method = tool.method.toUpperCase();
86105
const annotations: Record<string, any> = {
87106
readOnlyHint: method === 'GET' || method === 'HEAD',
88107
destructiveHint: method === 'DELETE',
108+
idempotentHint: ['GET', 'HEAD', 'PUT', 'DELETE'].includes(method),
109+
openWorldHint: false,
89110
};
90111

91-
const actionTitle = opts.actions?.[tool.name]?.title;
112+
const actionConfig = opts.actions?.[tool.name];
113+
114+
// Explicit per-action overrides beat derived values. Undefined-checked so
115+
// an explicit `false` override wins too.
116+
for (const hint of ANNOTATION_HINT_KEYS) {
117+
const override = actionConfig?.[hint];
118+
if (override !== undefined) {
119+
annotations[hint] = override;
120+
}
121+
}
122+
123+
const actionTitle = actionConfig?.title;
92124
if (actionTitle) {
93125
annotations.title = actionTitle;
94126
}
95127

96-
const description = opts.actions?.[tool.name]?.description ?? tool.description;
128+
const description = actionConfig?.description ?? tool.description;
97129

98130
const toolArgs = [`"${tool.name}"`, `"${description}"`, generateToolInputFromParsedTool(tool)];
99131

100-
// annotations always carry read-only/destructive hints, so always include them
132+
// annotations always carry the derived hint set, so always include them
101133
toolArgs.push(JSON.stringify(annotations));
102134

103135
toolArgs.push(`async (args) => {

0 commit comments

Comments
 (0)