Skip to content

Commit e8d730f

Browse files
authored
fix(connectors): return validation failures as MCP errors, not success results (#154)
The GitHub and Google Drive connectors returned { error: '...' } for user-facing validation failures, which normalized to a success McpToolResult, so an agent could not detect failure via the MCP isError flag. Return McpResponse.error(...) instead (isError: true). @gemstack/connectors now re-exports McpResponse (+ the McpToolResult type) so a connector's handle can signal these without importing @gemstack/mcp directly. Closes #147
1 parent a037b8c commit e8d730f

8 files changed

Lines changed: 62 additions & 13 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@gemstack/connectors': minor
3+
'@gemstack/connector-github': patch
4+
'@gemstack/connector-google-drive': patch
5+
---
6+
7+
Return validation failures as MCP errors instead of success results.
8+
9+
The GitHub and Google Drive connectors returned `{ error: '...' }` for user-facing validation failures (get-file on a directory, get-file-content on a folder, share-file missing an email/domain). These normalized to a **success** `McpToolResult`, so an agent could not tell failure from data via the MCP `isError` flag. They now return `McpResponse.error(...)`, which sets `isError: true`.
10+
11+
`@gemstack/connectors` now re-exports `McpResponse` (and the `McpToolResult` type) so a connector's `handle` can signal these errors without depending on `@gemstack/mcp` directly.

packages/connector-github/src/index.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ test('get-file base64-decodes content', async () => {
9090
assert.equal(res.path, 'README.md')
9191
})
9292

93+
test('get-file on a directory returns an MCP error, not a success result', async () => {
94+
// The contents API returns an array for a directory path.
95+
mockFetch(() => ({ body: [{ path: 'src/a.ts' }, { path: 'src/b.ts' }] }))
96+
const res = await client().callTool('github_get-file', { owner: 'o', repo: 'r', path: 'src' })
97+
assert.equal(res.isError, true)
98+
const first = res.content[0]
99+
assert.match(first && first.type === 'text' ? first.text : '', /is a directory/)
100+
})
101+
93102
test('comment-on-issue POSTs the body and returns the comment ref', async () => {
94103
mockFetch(() => ({ body: { id: 99, html_url: 'comment-url' } }))
95104
const res = json(await client().callTool('github_comment-on-issue', { owner: 'o', repo: 'r', number: 7, body: 'hi' }))

packages/connector-github/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineConnector } from '@gemstack/connectors'
1+
import { defineConnector, McpResponse } from '@gemstack/connectors'
22
import { z } from 'zod'
33
import { gh } from './client.js'
44

@@ -145,7 +145,7 @@ export default defineConnector({
145145
'GET',
146146
`/repos/${enc(input.owner)}/${enc(input.repo)}/contents/${input.path.split('/').map(enc).join('/')}${q}`,
147147
)
148-
if (Array.isArray(f)) return { error: `path "${input.path}" is a directory, not a file` }
148+
if (Array.isArray(f)) return McpResponse.error(`path "${input.path}" is a directory, not a file`)
149149
const content =
150150
f.encoding === 'base64' && typeof f.content === 'string'
151151
? Buffer.from(f.content, 'base64').toString('utf8')

packages/connector-google-drive/src/index.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ function json(result: McpToolResult): any {
4040
return first && first.type === 'text' ? JSON.parse(first.text) : undefined
4141
}
4242

43+
/** The plain error text of an `isError` result. */
44+
function errText(result: McpToolResult): string {
45+
const first = result.content[0]
46+
return first && first.type === 'text' ? first.text : ''
47+
}
48+
4349
/** Decode a captured request URL, restoring `+`-encoded spaces for readable matching. */
4450
function urlOf(i: number): string {
4551
return decodeURIComponent(calls[i]!.url).replace(/\+/g, ' ')
@@ -132,8 +138,9 @@ test('get-file-content downloads a regular file via alt=media', async () => {
132138

133139
test('get-file-content refuses a folder', async () => {
134140
mockFetch(() => ({ body: { id: 'x', name: 'Stuff', mimeType: 'application/vnd.google-apps.folder' } }))
135-
const res = json(await client().callTool('google-drive_get-file-content', { fileId: 'x' }))
136-
assert.match(res.error, /folder/)
141+
const res = await client().callTool('google-drive_get-file-content', { fileId: 'x' })
142+
assert.equal(res.isError, true)
143+
assert.match(errText(res), /folder/)
137144
assert.equal(calls.length, 1)
138145
})
139146

@@ -162,8 +169,9 @@ test('share-file posts a permission and validates the email requirement', async
162169
assert.deepEqual(calls[0]!.body, { role: 'writer', type: 'user', emailAddress: 'b@x.com' })
163170

164171
calls = []
165-
const bad = json(await client().callTool('google-drive_share-file', { fileId: 'F' }))
166-
assert.match(bad.error, /requires an emailAddress/)
172+
const bad = await client().callTool('google-drive_share-file', { fileId: 'F' })
173+
assert.equal(bad.isError, true)
174+
assert.match(errText(bad), /requires an emailAddress/)
167175
assert.equal(calls.length, 0)
168176
})
169177

packages/connector-google-drive/src/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineConnector } from '@gemstack/connectors'
1+
import { defineConnector, McpResponse } from '@gemstack/connectors'
22
import { z } from 'zod'
33
import { gd, gdText } from './client.js'
44

@@ -148,11 +148,11 @@ export default defineConnector({
148148
const id = enc(input.fileId)
149149
const meta = await gd<Record<string, any>>(ctx, 'GET', `/files/${id}?fields=id,name,mimeType`)
150150
const mime: string = meta.mimeType ?? ''
151-
if (mime === FOLDER_MIME) return { error: `"${meta.name}" is a folder, not a file` }
151+
if (mime === FOLDER_MIME) return McpResponse.error(`"${meta.name}" is a folder, not a file`)
152152
let content: string
153153
if (mime.startsWith('application/vnd.google-apps.')) {
154154
const exportMime = exportMimeFor(mime)
155-
if (!exportMime) return { error: `cannot export ${mime} as text` }
155+
if (!exportMime) return McpResponse.error(`cannot export ${mime} as text`)
156156
content = await gdText(ctx, `/files/${id}/export?mimeType=${enc(exportMime)}`)
157157
} else {
158158
content = await gdText(ctx, `/files/${id}?alt=media`)
@@ -209,9 +209,9 @@ export default defineConnector({
209209
) => {
210210
const type = input.type ?? 'user'
211211
if ((type === 'user' || type === 'group') && !input.emailAddress) {
212-
return { error: `type "${type}" requires an emailAddress` }
212+
return McpResponse.error(`type "${type}" requires an emailAddress`)
213213
}
214-
if (type === 'domain' && !input.domain) return { error: 'type "domain" requires a domain' }
214+
if (type === 'domain' && !input.domain) return McpResponse.error('type "domain" requires a domain')
215215
const payload: Record<string, unknown> = { role: input.role ?? 'reader', type }
216216
if (input.emailAddress) payload.emailAddress = input.emailAddress
217217
if (input.domain) payload.domain = input.domain

packages/connectors/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default defineConnector({
4444
})
4545
```
4646

47-
A tool's `handle` may return a `string` (wrapped as text), any JSON-serializable value (wrapped as pretty JSON), or a full `McpToolResult` (use `McpResponse` from `@gemstack/mcp` for errors / images).
47+
A tool's `handle` may return a `string` (wrapped as text), any JSON-serializable value (wrapped as pretty JSON), or a full `McpToolResult`. For an expected, user-facing failure (validation, not-found), return `McpResponse.error(...)` (re-exported from this package) so the client sees a failed tool call (`isError: true`) it can detect, rather than a success result that merely contains an `error` field. Reserve throwing for unexpected faults.
4848

4949
## Mount connectors into a server
5050

packages/connectors/src/index.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { test } from 'node:test'
33
import assert from 'node:assert/strict'
44
import { z } from 'zod'
55
import { McpTestClient } from '@gemstack/mcp/testing'
6-
import { defineConnector, mountConnectors } from './index.js'
6+
import { defineConnector, mountConnectors, McpResponse } from './index.js'
77
import type { ConnectorContext } from './index.js'
88
import type { McpToolResult } from '@gemstack/mcp'
99

@@ -110,6 +110,22 @@ test('annotations are advertised to clients', async () => {
110110
assert.equal(list?.annotations?.readOnlyHint, true)
111111
})
112112

113+
test('a handler returning McpResponse.error surfaces as an MCP error result', async () => {
114+
const c = defineConnector({
115+
id: 'guard',
116+
tools: [
117+
{
118+
name: 'check',
119+
schema: z.object({}),
120+
handle: () => McpResponse.error('not allowed'),
121+
},
122+
],
123+
})
124+
const res = await new McpTestClient(mountConnectors([c])).callTool('guard_check')
125+
assert.equal(res.isError, true)
126+
assert.equal(txt(res), 'Error: not allowed')
127+
})
128+
113129
test('mountConnectors rejects cross-connector name collisions without namespacing', () => {
114130
const a = defineConnector({ id: 'a', tools: [{ name: 'x', schema: z.object({}), handle: () => '' }] })
115131
const b = defineConnector({ id: 'b', tools: [{ name: 'x', schema: z.object({}), handle: () => '' }] })

packages/connectors/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
export { defineConnector } from './defineConnector.js'
22
export { mountConnectors } from './mountConnectors.js'
33
export type { ConnectorServerClass, MountOptions } from './mountConnectors.js'
4+
// Re-exported so a connector's `handle` can signal an expected, user-facing
5+
// failure as an MCP error (`isError: true`) via `McpResponse.error(...)`
6+
// without depending on `@gemstack/mcp` directly.
7+
export { McpResponse } from '@gemstack/mcp'
8+
export type { McpToolResult } from '@gemstack/mcp'
49
export type {
510
Connector,
611
ConnectorDefinition,

0 commit comments

Comments
 (0)