diff --git a/app/api/agent/introductions/route.js b/app/api/agent/introductions/route.js index a98df3a..7191ad7 100644 --- a/app/api/agent/introductions/route.js +++ b/app/api/agent/introductions/route.js @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { AgentRequestValidationError, authenticateAgent, + computeRetryAfterSeconds, createIntroductionDocument, normalizeIntroductionRequest, parseAgentKeys, @@ -21,30 +22,39 @@ function authenticateRequest(request) { return authenticateAgent(request.headers.get('authorization'), configuredKeys); } +/** + * Standard error envelope for this route: every non-2xx response carries a + * stable `code` (for programmatic handling) alongside the human message, so + * MCP tool callers can branch on `code` instead of parsing prose. + */ +function apiError(code, message, status) { + return NextResponse.json({ error: { code, message, retryable: status >= 500 } }, { status }); +} + export async function GET(request) { let agent; try { agent = authenticateRequest(request); } catch (error) { console.error('Agent key configuration error:', error.message); - return NextResponse.json({ error: 'Agent authentication is not configured' }, { status: 503 }); + return apiError('unavailable', 'Agent authentication is not configured', 503); } - if (!agent) return NextResponse.json({ error: 'Invalid or missing agent credentials' }, { status: 401 }); + if (!agent) return apiError('authentication_required', 'Invalid or missing agent credentials', 401); const { searchParams } = new URL(request.url); const id = searchParams.get('id'); const developerLogin = searchParams.get('developerLogin'); if (!/^[a-f\d-]{36}$/i.test(id || '') || !developerLogin) { - return NextResponse.json({ error: 'Request id and developer login are required' }, { status: 400 }); + return apiError('invalid_request', 'Request id and developer login are required', 400); } const introductions = getCosmosContainer(process.env.COSMOS_INTRODUCTIONS_CONTAINER || 'agent-introductions'); - if (!introductions) return NextResponse.json({ error: 'Introduction requests are not configured' }, { status: 503 }); + if (!introductions) return apiError('unavailable', 'Introduction requests are not configured', 503); try { const { resource } = await introductions.item(id, developerLogin).read(); if (!resource || resource.agentId !== agent.id) { - return NextResponse.json({ error: 'Introduction request not found' }, { status: 404 }); + return apiError('not_found', 'Introduction request not found', 404); } const expired = resource.status === 'pending' && resource.expiresAt <= new Date().toISOString(); const status = expired ? 'expired' : resource.status; @@ -64,9 +74,9 @@ export async function GET(request) { }, }, { headers: { 'Cache-Control': 'no-store' } }); } catch (error) { - if (error.code === 404) return NextResponse.json({ error: 'Introduction request not found' }, { status: 404 }); + if (error.code === 404) return apiError('not_found', 'Introduction request not found', 404); console.error('Agent introduction status error:', error.message); - return NextResponse.json({ error: 'Failed to load introduction request' }, { status: 500 }); + return apiError('upstream_error', 'Failed to load introduction request', 500); } } @@ -76,11 +86,11 @@ export async function POST(request) { agent = authenticateRequest(request); } catch (error) { console.error('Agent key configuration error:', error.message); - return NextResponse.json({ error: 'Agent authentication is not configured' }, { status: 503 }); + return apiError('unavailable', 'Agent authentication is not configured', 503); } if (!agent) { - return NextResponse.json({ error: 'Invalid or missing agent credentials' }, { status: 401 }); + return apiError('authentication_required', 'Invalid or missing agent credentials', 401); } let input; @@ -88,13 +98,13 @@ export async function POST(request) { input = normalizeIntroductionRequest(await request.json()); } catch (error) { const message = error instanceof AgentRequestValidationError ? error.message : 'Invalid request body'; - return NextResponse.json({ error: message }, { status: 400 }); + return apiError('invalid_request', message, 400); } const developers = getCosmosContainer(); const introductions = getCosmosContainer(process.env.COSMOS_INTRODUCTIONS_CONTAINER || 'agent-introductions'); if (!developers || !introductions) { - return NextResponse.json({ error: 'Introduction requests are not configured' }, { status: 503 }); + return apiError('unavailable', 'Introduction requests are not configured', 503); } try { @@ -108,19 +118,27 @@ export async function POST(request) { }).fetchAll(); const publicAiProfile = getPublicAiProfile(matches[0]?.aiProfile); if (!publicAiProfile?.acceptsAgentRequests || publicAiProfile.contactPolicy !== 'verified-agents') { - return NextResponse.json({ error: 'Developer is not accepting verified agent requests' }, { status: 409 }); + return apiError('conflict', 'Developer is not accepting verified agent requests', 409); } const since = new Date(Date.now() - RATE_LIMIT_WINDOW_MS).toISOString(); - const { resources: counts } = await introductions.items.query({ - query: 'SELECT VALUE COUNT(1) FROM c WHERE c.agentId = @agentId AND c.createdAt >= @since', + const { resources: windowRequests } = await introductions.items.query({ + query: 'SELECT c.createdAt FROM c WHERE c.agentId = @agentId AND c.createdAt >= @since ORDER BY c.createdAt ASC', parameters: [ { name: '@agentId', value: agent.id }, { name: '@since', value: since }, ], }).fetchAll(); - if ((counts[0] || 0) >= getRateLimit()) { - return NextResponse.json({ error: 'Agent introduction rate limit exceeded' }, { status: 429 }); + if (windowRequests.length >= getRateLimit()) { + const retryAfterSeconds = computeRetryAfterSeconds(windowRequests[0].createdAt, RATE_LIMIT_WINDOW_MS); + return NextResponse.json({ + error: { + code: 'rate_limited', + message: 'Agent introduction rate limit exceeded', + retryable: true, + retryAfterSeconds, + }, + }, { status: 429, headers: { 'Retry-After': String(retryAfterSeconds) } }); } const document = createIntroductionDocument(input, agent); @@ -138,6 +156,6 @@ export async function POST(request) { }, { status: 201 }); } catch (error) { console.error('Agent introduction error:', error.message); - return NextResponse.json({ error: 'Failed to create introduction request' }, { status: 500 }); + return apiError('upstream_error', 'Failed to create introduction request', 500); } } diff --git a/docs-site/agents/mcp.md b/docs-site/agents/mcp.md index c008d0f..945c7d9 100644 --- a/docs-site/agents/mcp.md +++ b/docs-site/agents/mcp.md @@ -59,6 +59,26 @@ Discovery tools return MCP `structuredContent` with stable schemas while retaini The endpoint advertises its [MCP server card](https://www.devglobe.dev/.well-known/mcp/server-card.json), documentation, and [Agent Skill index](https://www.devglobe.dev/.well-known/agent-skills/index.json) through HTTP `Link` headers. +## Errors and retry guidance + +Every tool error is a structured envelope, not a bare string: + +```json +{ "error": { "code": "rate_limited", "message": "...", "retryable": true, "retryAfterSeconds": 1800 } } +``` + +| Code | Retryable | Notes | +|---|---|---| +| `authentication_required` | No | Fix the bearer token first | +| `invalid_request` | No | Fix the tool input first | +| `not_found` | No | Unknown developer login or request id | +| `conflict` | No | Developer isn't accepting verified agent requests | +| `rate_limited` | Yes | Wait `retryAfterSeconds` before retrying `request_introduction` | +| `unavailable` | Yes | Transient backend/config issue; back off | +| `upstream_error` | Yes | Unexpected failure calling DevGlobe; back off exponentially | + +Only retryable errors ever include `retryAfterSeconds`, and only when DevGlobe can compute a concrete wait. When it's absent on a retryable error, use exponential backoff (e.g. 1s, 2s, 4s, capped, up to 3 attempts) instead of retrying immediately. Never retry a non-retryable error without changing the input or credentials — the outcome won't change. + ## Privacy-safe telemetry DevGlobe records the MCP method, known tool name, success or error outcome, latency, and aggregate result count. Raw prompts, search arguments, profile content, credentials, and private contact details are not included in usage events. @@ -93,4 +113,4 @@ Clients without Streamable HTTP support can run the included bridge: } ``` -The hosted endpoint intentionally does not create server-side MCP sessions; `GET` and `DELETE` session operations are unsupported. \ No newline at end of file +The hosted endpoint intentionally does not create server-side MCP sessions; `GET` and `DELETE` session operations are unsupported. diff --git a/docs-site/reference/api.md b/docs-site/reference/api.md index 6a2141c..c6fa1da 100644 --- a/docs-site/reference/api.md +++ b/docs-site/reference/api.md @@ -39,7 +39,8 @@ Use an MCP SDK rather than constructing protocol payloads by hand. See the [MCP - Treat `404` as an unknown or unindexed profile. - Back off on `429` and do not fan out profile requests unnecessarily. - Expect optional metrics and source freshness timestamps. +- MCP tool errors (including `429` on `request_introduction`) use a structured `{ error: { code, message, retryable, retryAfterSeconds } }` envelope. See [error codes and retry guidance](../agents/mcp#errors-and-retry-guidance) before implementing retry logic. ## Privacy boundary -Public endpoints do not return private email addresses, watchlists, saved searches, contact preferences, agent credentials, or private AI profile settings. Do not combine public signals to infer protected or private attributes. \ No newline at end of file +Public endpoints do not return private email addresses, watchlists, saved searches, contact preferences, agent credentials, or private AI profile settings. Do not combine public signals to infer protected or private attributes. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 6df5c35..ec4ec27 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -116,6 +116,33 @@ DEVGLOBE_MCP_ALLOWED_ORIGINS=https://trusted-agent-console.example The endpoint does not create server-side MCP sessions. `GET` and `DELETE` session operations are intentionally unsupported, while tool calls use `POST` requests. +## Errors & Retry Guidance + +Every MCP tool error returns a structured envelope instead of a bare string: + +```json +{ + "error": { + "code": "rate_limited", + "message": "Agent introduction rate limit exceeded", + "retryable": true, + "retryAfterSeconds": 1800 + } +} +``` + +| Code | Meaning | Retryable | Guidance | +| --- | --- | --- | --- | +| `authentication_required` | Missing or invalid `DEVGLOBE_AGENT_TOKEN` / bearer credential | No | Fix credentials before retrying; retrying without changing the token will always fail the same way. | +| `invalid_request` | Input failed validation (bad login, reason too short, etc.) | No | Correct the input; retrying unchanged input will always fail. | +| `not_found` | Referenced developer or request id does not exist | No | Do not retry; re-check the id or login. | +| `conflict` | The developer is not accepting verified agent requests | No | Do not retry; the developer's consent settings, not a transient condition, caused this. | +| `rate_limited` | The per-agent introduction rate limit was exceeded | Yes | Wait at least `retryAfterSeconds` (also echoed in the HTTP `Retry-After` header on the underlying API response) before retrying the same call. | +| `unavailable` | A required backend dependency isn't configured | Yes | Safe to retry with backoff; this reflects a temporary deployment/config issue, not the request itself. | +| `upstream_error` | An unexpected failure calling DevGlobe's API | Yes | Retry with exponential backoff (e.g. 1s, 2s, 4s, capped, up to 3 attempts) before surfacing the failure. | + +Only `error.retryable === true` responses include `retryAfterSeconds`, and only when DevGlobe can compute a concrete wait time (currently: rate limiting on `request_introduction`). When `retryAfterSeconds` is absent on a retryable error, use exponential backoff instead of retrying immediately. Never retry a non-retryable error without changing the input or credentials first — repeating it will not change the outcome and wastes the agent's rate-limit budget. + ## Consent Lifecycle 1. An authenticated agent requests an introduction to a public, opted-in profile. diff --git a/lib/agent-introductions.js b/lib/agent-introductions.js index 8d1e0d1..0311470 100644 --- a/lib/agent-introductions.js +++ b/lib/agent-introductions.js @@ -13,6 +13,16 @@ export function hashAgentToken(token) { return createHash('sha256').update(token).digest('hex'); } +/** + * Computes how many seconds an agent must wait before its oldest request in + * the current rate-limit window rolls off, so a 429 response can advertise a + * concrete, honest Retry-After value instead of a fixed guess. + */ +export function computeRetryAfterSeconds(oldestRequestAt, windowMs, now = new Date()) { + const resetAt = new Date(oldestRequestAt).getTime() + windowMs; + return Math.max(1, Math.ceil((resetAt - now.getTime()) / 1000)); +} + export function parseAgentKeys(value) { if (!value) return []; diff --git a/lib/devglobe-mcp-client.js b/lib/devglobe-mcp-client.js index 151a67b..4561691 100644 --- a/lib/devglobe-mcp-client.js +++ b/lib/devglobe-mcp-client.js @@ -60,7 +60,20 @@ function projectDeveloper(developer, origin, input) { async function readJson(response) { const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || `DevGlobe API returned ${response.status}`); + if (!response.ok) { + // The API returns either a plain string error (older/simple routes) or a + // structured { code, message, retryable, retryAfterSeconds } envelope + // (routes with a retry/rate-limit contract, e.g. agent introductions). + const errorInfo = typeof data.error === 'string' ? { message: data.error } : (data.error || {}); + const error = new Error(errorInfo.message || `DevGlobe API returned ${response.status}`); + error.status = response.status; + if (errorInfo.code) error.code = errorInfo.code; + if (typeof errorInfo.retryable === 'boolean') error.retryable = errorInfo.retryable; + const retryAfterHeader = response.headers.get?.('retry-after'); + const retryAfterSeconds = errorInfo.retryAfterSeconds ?? (retryAfterHeader ? Number(retryAfterHeader) : undefined); + if (Number.isFinite(retryAfterSeconds)) error.retryAfterSeconds = retryAfterSeconds; + throw error; + } return data; } diff --git a/lib/devglobe-mcp-server.js b/lib/devglobe-mcp-server.js index a9753a1..0ae7791 100644 --- a/lib/devglobe-mcp-server.js +++ b/lib/devglobe-mcp-server.js @@ -36,20 +36,37 @@ function toolResult(value, structuredContent) { }; } -function toolError(error) { +// Tools that are safe to retry automatically after the given delay, without +// agent-side judgment. Anything else (bad input, missing consent, unknown +// developer, etc.) should surface to the operator instead of looping. +const RETRYABLE_CODES = new Set(['rate_limited', 'upstream_error', 'unavailable']); + +export function toolError(error) { const message = error instanceof Error ? error.message : 'Unexpected DevGlobe error'; const normalized = message.toLowerCase(); - const code = normalized.includes('token') || normalized.includes('credential') - ? 'authentication_required' - : normalized.includes('rate limit') - ? 'rate_limited' - : normalized.includes('not found') - ? 'not_found' - : 'upstream_error'; + // Prefer a code the API already classified for us (see readJson in + // devglobe-mcp-client.js); fall back to message sniffing for errors raised + // directly in the client (e.g. a missing DEVGLOBE_AGENT_TOKEN) that never + // reach an HTTP response. + const code = error?.code + || (normalized.includes('token') || normalized.includes('credential') + ? 'authentication_required' + : normalized.includes('rate limit') + ? 'rate_limited' + : normalized.includes('not found') + ? 'not_found' + : 'upstream_error'); + const retryable = typeof error?.retryable === 'boolean' ? error.retryable : RETRYABLE_CODES.has(code); + const retryAfterSeconds = error?.retryAfterSeconds; return { isError: true, content: [{ type: 'text', text: JSON.stringify({ - error: { code, message, retryable: code === 'rate_limited' || code === 'upstream_error' }, + error: { + code, + message, + retryable, + ...(retryable && Number.isFinite(retryAfterSeconds) ? { retryAfterSeconds } : {}), + }, }) }], }; } @@ -157,4 +174,4 @@ export function createDevGlobeMcpServer({ client = createDevGlobeMcpClient() } = }); return server; -} \ No newline at end of file +} diff --git a/tests/mcp-retry-contract.test.js b/tests/mcp-retry-contract.test.js new file mode 100644 index 0000000..cfbf890 --- /dev/null +++ b/tests/mcp-retry-contract.test.js @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { computeRetryAfterSeconds } from '../lib/agent-introductions.js'; +import { createDevGlobeMcpClient } from '../lib/devglobe-mcp-client.js'; +import { toolError } from '../lib/devglobe-mcp-server.js'; + +test('computeRetryAfterSeconds reflects when the oldest request rolls off the window', () => { + const now = new Date('2026-08-20T12:30:00.000Z'); + const oldestRequestAt = '2026-08-20T12:00:00.000Z'; // 30 minutes ago + const windowMs = 60 * 60 * 1000; // 1 hour + + // 60 minutes since the oldest request, minus the 30 already elapsed = 30 minutes left. + assert.equal(computeRetryAfterSeconds(oldestRequestAt, windowMs, now), 30 * 60); +}); + +test('computeRetryAfterSeconds never returns a non-positive value', () => { + const now = new Date('2026-08-20T13:00:00.000Z'); + const oldestRequestAt = '2026-08-20T12:00:00.000Z'; // already outside the window + const windowMs = 60 * 60 * 1000; + + assert.equal(computeRetryAfterSeconds(oldestRequestAt, windowMs, now), 1); +}); + +test('MCP client surfaces structured rate-limit errors with retryAfterSeconds', async () => { + const client = createDevGlobeMcpClient({ + baseUrl: 'https://devglobe.dev', + agentToken: 'issued-token', + fetchImpl: async () => new Response(JSON.stringify({ + error: { + code: 'rate_limited', + message: 'Agent introduction rate limit exceeded', + retryable: true, + retryAfterSeconds: 1800, + }, + }), { status: 429, headers: { 'Retry-After': '1800' } }), + }); + + await assert.rejects( + () => client.requestIntroduction({ developerLogin: 'octocat', reason: 'x'.repeat(20), project: 'Demo' }), + error => { + assert.equal(error.status, 429); + assert.equal(error.code, 'rate_limited'); + assert.equal(error.retryable, true); + assert.equal(error.retryAfterSeconds, 1800); + return true; + }, + ); +}); + +test('MCP client falls back to the Retry-After header when the body omits retryAfterSeconds', async () => { + const client = createDevGlobeMcpClient({ + baseUrl: 'https://devglobe.dev', + agentToken: 'issued-token', + fetchImpl: async () => new Response(JSON.stringify({ + error: { code: 'rate_limited', message: 'Too many requests' }, + }), { status: 429, headers: { 'Retry-After': '42' } }), + }); + + await assert.rejects( + () => client.getIntroductionStatus({ id: 'e6fa6dc6-64df-48c4-8597-c70bfe089bec', developerLogin: 'octocat' }), + error => { + assert.equal(error.retryAfterSeconds, 42); + return true; + }, + ); +}); + +test('MCP client still handles older plain-string error bodies', async () => { + const client = createDevGlobeMcpClient({ + baseUrl: 'https://devglobe.dev', + agentToken: 'issued-token', + fetchImpl: async () => new Response(JSON.stringify({ error: 'Introduction request not found' }), { status: 404 }), + }); + + await assert.rejects( + () => client.getIntroductionStatus({ id: 'e6fa6dc6-64df-48c4-8597-c70bfe089bec', developerLogin: 'octocat' }), + error => { + assert.equal(error.message, 'Introduction request not found'); + assert.equal(error.code, undefined); + assert.equal(error.retryAfterSeconds, undefined); + return true; + }, + ); +}); + +test('toolError marks rate-limit errors retryable and includes retryAfterSeconds', () => { + const upstream = new Error('Agent introduction rate limit exceeded'); + upstream.code = 'rate_limited'; + upstream.retryable = true; + upstream.retryAfterSeconds = 900; + + const result = toolError(upstream); + const payload = JSON.parse(result.content[0].text); + + assert.equal(result.isError, true); + assert.equal(payload.error.code, 'rate_limited'); + assert.equal(payload.error.retryable, true); + assert.equal(payload.error.retryAfterSeconds, 900); +}); + +test('toolError does not invent a retry delay for non-retryable errors', () => { + const upstream = new Error('Introduction request not found'); + upstream.code = 'not_found'; + upstream.retryable = false; + + const result = toolError(upstream); + const payload = JSON.parse(result.content[0].text); + + assert.equal(payload.error.code, 'not_found'); + assert.equal(payload.error.retryable, false); + assert.equal('retryAfterSeconds' in payload.error, false); +}); + +test('toolError falls back to message sniffing for errors without a code', () => { + const upstream = new Error('DEVGLOBE_AGENT_TOKEN is required for introduction requests'); + + const result = toolError(upstream); + const payload = JSON.parse(result.content[0].text); + + assert.equal(payload.error.code, 'authentication_required'); + assert.equal(payload.error.retryable, false); +}); + +test('toolError treats unclassified upstream failures as retryable', () => { + const upstream = new Error('DevGlobe API returned 500'); + + const result = toolError(upstream); + const payload = JSON.parse(result.content[0].text); + + assert.equal(payload.error.code, 'upstream_error'); + assert.equal(payload.error.retryable, true); +});