Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/mcp/src/microlink-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
37 changes: 21 additions & 16 deletions packages/mcp/test/errors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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')
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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' })
Expand All @@ -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'
})
})
Expand All @@ -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)
})

Expand All @@ -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')
Expand All @@ -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'
})
})
Expand All @@ -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 => {
Expand Down Expand Up @@ -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/)
})
19 changes: 12 additions & 7 deletions packages/mcp/test/output-schemas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
)
})
12 changes: 7 additions & 5 deletions packages/mcp/test/tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
)
Expand Down Expand Up @@ -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))
})