diff --git a/packages/mcp/src/microlink-client.js b/packages/mcp/src/microlink-client.js index 5ca928a..0e186cf 100644 --- a/packages/mcp/src/microlink-client.js +++ b/packages/mcp/src/microlink-client.js @@ -36,11 +36,18 @@ const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value) function toToolResponse (isError, field, value) { - return { + const result = { isError, - structuredContent: { [field]: value }, content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] } + + // A tool's outputSchema describes successful structuredContent. Clients may + // validate any structuredContent they receive against it, including errors. + // Keep errors in backwards-compatible text content so those clients surface + // the actionable payload instead of replacing it with a validation failure. + if (!isError) result.structuredContent = { [field]: value } + + return result } // Every tool returns the library's direct result (a string, array, or object). diff --git a/packages/mcp/test/errors.test.js b/packages/mcp/test/errors.test.js index 0106539..2e192fb 100644 --- a/packages/mcp/test/errors.test.js +++ b/packages/mcp/test/errors.test.js @@ -26,7 +26,7 @@ const apiError = ({ statusCode = 400, ...body }) => test('EPROXYNEEDED explains the cause and how to continue', () => { const result = asErrorResult(apiError(PROXY_ERROR_BODY)) - const error = result.structuredContent.error + const error = JSON.parse(result.content[0].text) assert.equal(result.isError, true) assert.equal( @@ -63,7 +63,7 @@ test('EINTEGRATION points to the PRO plan requirement', () => { message: GENERIC_API_MESSAGE }) ) - const error = result.structuredContent.error + const error = JSON.parse(result.content[0].text) assert.equal(error.message, 'You need a pro plan for using integrations.') assert.equal(error.code, 'EINTEGRATION') @@ -85,7 +85,7 @@ test('codes without guidance keep the specific message and no upgrade fields', ( message: GENERIC_API_MESSAGE }) ) - const error = result.structuredContent.error + const error = JSON.parse(result.content[0].text) assert.equal( error.message, @@ -102,7 +102,7 @@ test('errors without data details keep the original message', () => { const result = asErrorResult( apiError({ status: 'fail', code: 'ETEST', message: 'boom' }) ) - const error = result.structuredContent.error + const error = JSON.parse(result.content[0].text) assert.equal(error.message, 'boom') assert.equal(error.details, undefined) @@ -117,7 +117,7 @@ test('a specific API description wins over auxiliary data strings', () => { message: 'The target URL is unreachable.' }) ) - const error = result.structuredContent.error + const error = JSON.parse(result.content[0].text) assert.equal(error.message, 'The target URL is unreachable.') assert.deepEqual(error.details, { url: 'https://example.com' }) @@ -127,7 +127,8 @@ test('non-Error thrown values fall back to String(error)', () => { const result = asErrorResult('plain failure') assert.equal(result.isError, true) - assert.deepEqual(result.structuredContent.error, { + assert.equal(result.structuredContent, undefined) + assert.deepEqual(JSON.parse(result.content[0].text), { message: 'plain failure' }) }) @@ -137,7 +138,7 @@ test('plain error payloads are wrapped without rewriting the message', () => { const result = asErrorResult(payload) assert.equal(result.isError, true) - assert.equal(result.structuredContent.error, payload) + assert.equal(result.structuredContent, undefined) assert.deepEqual(JSON.parse(result.content[0].text), payload) }) @@ -150,7 +151,7 @@ test('429 keeps the quota hint and exposes a machine-readable reason', () => { message: 'Rate limit exceeded.' }) ) - const error = result.structuredContent.error + const error = JSON.parse(result.content[0].text) assert.equal(error.message, 'Rate limit exceeded.') assert.equal(error.reason, 'quota_exceeded') @@ -162,7 +163,8 @@ test('non-Microlink errors only expose the message', () => { const result = asErrorResult(new Error('fetch failed')) assert.equal(result.isError, true) - assert.deepEqual(result.structuredContent.error, { + assert.equal(result.structuredContent, undefined) + assert.deepEqual(JSON.parse(result.content[0].text), { message: 'fetch failed' }) }) @@ -172,10 +174,11 @@ test('the text content mirrors the structured error', () => { assert.equal(result.content.length, 1) assert.equal(result.content[0].type, 'text') - assert.deepEqual( - JSON.parse(result.content[0].text), - result.structuredContent.error - ) + assert.equal(result.structuredContent, undefined) + const error = JSON.parse(result.content[0].text) + assert.equal(error.code, 'EPROXYNEEDED') + assert.equal(error.reason, 'upgrade_required') + assert.match(error.hint, /repeat the same call/) }) test('tool handlers surface actionable proxy errors end to end', async t => { @@ -208,7 +211,9 @@ test('tool handlers surface actionable proxy errors end to end', async t => { ) assert.equal(res.isError, true) - assert.equal(res.structuredContent.error.code, 'EPROXYNEEDED') - assert.equal(res.structuredContent.error.reason, 'upgrade_required') - assert.match(res.structuredContent.error.message, /antibot protection/) + assert.equal(res.structuredContent, undefined) + const error = JSON.parse(res.content[0].text) + assert.equal(error.code, 'EPROXYNEEDED') + assert.equal(error.reason, 'upgrade_required') + assert.match(error.message, /antibot protection/) }) diff --git a/packages/mcp/test/output-schemas.test.js b/packages/mcp/test/output-schemas.test.js index d0fe543..7fa4ab7 100644 --- a/packages/mcp/test/output-schemas.test.js +++ b/packages/mcp/test/output-schemas.test.js @@ -171,11 +171,16 @@ test('search validates web, news and autocomplete projections', () => { ) }) -test('error results are exempt from output validation by SDK contract', () => { - // The MCP SDK skips outputSchema validation when `isError` is true, and - // error results carry `structuredContent.error`, never `data`. Assert the - // registered handlers keep that split: an error payload must NOT match the - // success schema, proving the exemption is load-bearing. - const schema = dataSchema('microlink_metadata') - assert.equal(schema.safeParse({ error: { message: 'boom' } }).success, false) +test('error results omit success-only structured content', async () => { + const result = await registered.microlink_metadata.handler( + { url: 'not-a-url' }, + {} + ) + + assert.equal(result.isError, true) + assert.equal(result.structuredContent, undefined) + assert.equal( + JSON.parse(result.content[0].text).message, + 'Input validation failed.' + ) }) diff --git a/packages/mcp/test/tools.test.js b/packages/mcp/test/tools.test.js index 8414903..6d2e0d4 100644 --- a/packages/mcp/test/tools.test.js +++ b/packages/mcp/test/tools.test.js @@ -317,7 +317,8 @@ test('errors are surfaced as MCP isError with code/message', async t => { {} ) assert.equal(res.isError, true) - assert.ok(res.structuredContent.error.message) + assert.equal(res.structuredContent, undefined) + assert.ok(JSON.parse(res.content[0].text).message) }, { status: 400 } ) @@ -352,13 +353,14 @@ test('tools declare read-only annotations; microlink_function is the exception', }) }) -test('invalid input returns the same error envelope as structuredContent', async t => { +test('invalid input returns an MCP error without success-only structuredContent', async t => { const handlers = captureTool(metadata) const res = await handlers.microlink_metadata({}, {}) assert.equal(res.isError, true) - assert.equal(res.structuredContent.error.message, 'Input validation failed.') - assert.ok(Array.isArray(res.structuredContent.error.issues)) - assert.deepEqual(JSON.parse(res.content[0].text), res.structuredContent.error) + assert.equal(res.structuredContent, undefined) + const error = JSON.parse(res.content[0].text) + assert.equal(error.message, 'Input validation failed.') + assert.ok(Array.isArray(error.issues)) })