Skip to content

Commit 7868ac3

Browse files
kinlaneclaude
andcommitted
engine: sanitize OpenAPI summaries/descriptions to plain text
Generated MCP tool + Agent Skill descriptions carried the source spec's raw HTML (Stripe et al. ship <p>…</p>/<a> in operation descriptions), so a forged tool read "Stripe Get Charges — <p>Use the <a href=…". Add plain(): strip tags, unescape common entities, collapse whitespace (also fixes "Get Charges" double-spacing), trim — applied at the parse point so all downstream tool/skill/param descriptions are clean text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3ce76ec commit 7868ac3

1 file changed

Lines changed: 13 additions & 2 deletions

File tree

src/engine.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,13 @@ export function operations(doc: any): Operation[] {
7272
const responses = op.responses || {};
7373
const okCode = Object.keys(responses).find((c) => /^2/.test(c));
7474
if (okCode) { const r = deref(doc, responses[okCode]); if (r?.content) { const ct = pickJson(r.content); if (ct) responseSchema = ct.schema; } }
75-
out.push({ key: `${m} ${path}`, method: m, path, operationId: opId, summary: op.summary || '', description: op.description || '', tags: op.tags || [], params, requestSchema, responseSchema });
75+
out.push({ key: `${m} ${path}`, method: m, path, operationId: opId, summary: plain(op.summary || ''), description: plain(op.description || ''), tags: op.tags || [], params, requestSchema, responseSchema });
7676
}
7777
}
7878
return out;
7979
}
8080
const pickJson = (content: any) => content['application/json'] || content[Object.keys(content)[0]];
81-
function normParam(doc: any, p: any): OpParam { p = deref(doc, p); return { name: p.name, in: p.in, required: !!p.required, schema: p.schema, description: p.description }; }
81+
function normParam(doc: any, p: any): OpParam { p = deref(doc, p); return { name: p.name, in: p.in, required: !!p.required, schema: p.schema, description: p.description ? plain(p.description) : undefined }; }
8282

8383
// ---- extension access (design lives on the doc) ------------------------------
8484
function opNode(doc: any, op: Operation): any { return doc.paths?.[op.path]?.[op.method]; }
@@ -107,6 +107,17 @@ export function setRootSkills(doc: any, s: SkillDesign[]) {
107107
export const kebab = (s: string) => String(s || '').replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-');
108108
export const snake = (s: string) => kebab(s).replace(/-/g, '_');
109109

110+
// OpenAPI summaries/descriptions are often HTML (Stripe et al. ship <p>…</p>, <a>, code blocks).
111+
// A generated MCP tool/skill description must be PLAIN TEXT — an agent reading "<p>Use the <a…"
112+
// is worse off than one reading a clean sentence. Strip tags, unescape the common entities,
113+
// collapse whitespace (kills the "Get Charges" double-spacing too), and trim.
114+
export const plain = (s: string) => String(s || '')
115+
.replace(/<\/(p|div|li|h[1-6]|br)>/gi, ' ')
116+
.replace(/<[^>]+>/g, '')
117+
.replace(/&(nbsp|amp|lt|gt|quot|#39|apos);/g, (_m, e) => ({ nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", apos: "'" } as Record<string, string>)[e] || ' ')
118+
.replace(/\s+/g, ' ')
119+
.trim();
120+
110121
export function draftServer(doc: any): ServerDesign {
111122
const title = doc.info?.title || 'My API';
112123
const server = doc.servers?.[0]?.url || 'https://api.example.com';

0 commit comments

Comments
 (0)