From e116d200aaf426bd010e8a2f0ca390a70e68e5d9 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 17:58:50 +0500 Subject: [PATCH 01/11] Update route.js --- app/api/agent/introductions/route.js | 52 +++++++++++++++++++--------- 1 file changed, 35 insertions(+), 17 deletions(-) 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); } } From d3890b268831083cab865b1aa22962e75b5d69af Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:01:39 +0500 Subject: [PATCH 02/11] Update agent-introductions.js --- lib/agent-introductions.js | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 []; From 8f1757339ead3a42114710dffeb948f83b039d2c Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:02:53 +0500 Subject: [PATCH 03/11] Update devglobe-mcp-client.js --- lib/devglobe-mcp-client.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/devglobe-mcp-client.js b/lib/devglobe-mcp-client.js index 151a67b..b951dda 100644 --- a/lib/devglobe-mcp-client.js +++ b/lib/devglobe-mcp-client.js @@ -27,9 +27,6 @@ function explainMatch(developer, input = {}) { if (input.location && developer.location?.toLowerCase().includes(input.location.toLowerCase())) { reasons.push(`Location matches ${input.location}`); } - if (input.opportunityType && developer.aiProfile?.opportunityPreferences?.types?.includes(input.opportunityType)) { - reasons.push(`Developer is actively open to ${input.opportunityType} opportunities`); - } const query = String(input.query || '').trim(); if (query) reasons.push(`Matched the public search intent: ${query}`); return reasons.length ? reasons : ['Retrieved by GitHub login']; @@ -37,7 +34,6 @@ function explainMatch(developer, input = {}) { function projectDeveloper(developer, origin, input) { const updatedAt = developer.metricsUpdatedAt || null; - const opportunityPreferences = developer.aiProfile?.opportunityPreferences; return { login: developer.login, name: developer.name || developer.login, @@ -53,14 +49,26 @@ function projectDeveloper(developer, origin, input) { status: updatedAt ? 'reported' : 'unknown', }, availableForAgents: developer.aiProfile?.acceptsAgentRequests === true, - ...(opportunityPreferences ? { opportunityPreferences } : {}), methodologyDisclaimer: MCP_METHODOLOGY_DISCLAIMER, }; } 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; } @@ -72,7 +80,7 @@ export function createDevGlobeMcpClient({ const origin = normalizeBaseUrl(baseUrl); return { - async searchDevelopers({ query, location, language, opportunityType, availableForAgents = false, limit = 10 }) { + async searchDevelopers({ query, location, language, availableForAgents = false, limit = 10 }) { const searchText = [query, location, language].filter(Boolean).join(' '); const searchUrl = new URL('/api/search', origin); searchUrl.searchParams.set('q', searchText); @@ -90,9 +98,8 @@ export function createDevGlobeMcpClient({ const matchesLocation = !location || developer.location?.toLowerCase().includes(location.toLowerCase()); const matchesLanguage = !language || developer.topLanguage?.toLowerCase() === language.toLowerCase(); const matchesAvailability = !availableForAgents || developer.aiProfile?.acceptsAgentRequests === true; - const matchesOpportunity = !opportunityType || developer.aiProfile?.opportunityPreferences?.types?.includes(opportunityType); - return matchesLocation && matchesLanguage && matchesAvailability && matchesOpportunity; - }).slice(0, limit).map(developer => projectDeveloper(developer, origin, { query, location, language, opportunityType })); + return matchesLocation && matchesLanguage && matchesAvailability; + }).slice(0, limit).map(developer => projectDeveloper(developer, origin, { query, location, language })); }, async getDeveloperProfile(login) { From 2bc3a6e26252a0890c09afd8781a59d20fbd2fcf Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:03:21 +0500 Subject: [PATCH 04/11] Refactor opportunity preferences and error handling Removed opportunity preferences schema and related references. Updated toolError function to improve error handling. --- lib/devglobe-mcp-server.js | 51 +++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/lib/devglobe-mcp-server.js b/lib/devglobe-mcp-server.js index a9753a1..8d38960 100644 --- a/lib/devglobe-mcp-server.js +++ b/lib/devglobe-mcp-server.js @@ -1,18 +1,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { createDevGlobeMcpClient, MCP_METHODOLOGY_DISCLAIMER } from './devglobe-mcp-client.js'; -import { OPPORTUNITY_TYPES } from './ai-profile.js'; const evidenceSchema = z.object({ label: z.string(), value: z.number() }); -const opportunityPreferencesSchema = z.object({ - enabled: z.literal(true), - types: z.array(z.enum(OPPORTUNITY_TYPES)), - roles: z.array(z.string()), - locations: z.array(z.string()), - workModes: z.array(z.enum(['remote', 'hybrid', 'onsite'])), - expiresAt: z.string().datetime(), - source: z.literal('self-declared'), -}); const developerSchema = z.object({ login: z.string(), name: z.string(), @@ -25,7 +15,6 @@ const developerSchema = z.object({ publicEvidence: z.array(evidenceSchema), dataFreshness: z.object({ updatedAt: z.string().nullable(), status: z.enum(['reported', 'unknown']) }), availableForAgents: z.boolean(), - opportunityPreferences: opportunityPreferencesSchema.optional(), methodologyDisclaimer: z.string(), }); @@ -36,20 +25,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 } : {}), + }, }) }], }; } @@ -58,12 +64,11 @@ export function createDevGlobeMcpServer({ client = createDevGlobeMcpClient() } = const server = new McpServer({ name: 'devglobe', version: '1.0.0' }); server.registerTool('search_developers', { - description: 'Search public DevGlobe developer profiles by expertise, location, language, agent availability, and active self-declared opportunity intent.', + description: 'Search public DevGlobe developer profiles by expertise, location, language, and agent availability.', inputSchema: { query: z.string().min(1).max(200).describe('Skills, expertise, name, or other search intent'), location: z.string().max(100).optional(), language: z.string().max(50).optional(), - opportunityType: z.enum(OPPORTUNITY_TYPES).optional(), availableForAgents: z.boolean().default(false), limit: z.number().int().min(1).max(20).default(10), }, @@ -157,4 +162,4 @@ export function createDevGlobeMcpServer({ client = createDevGlobeMcpClient() } = }); return server; -} \ No newline at end of file +} From 59b6ec8467af328689a9518024a2c8eaeb46a941 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:03:55 +0500 Subject: [PATCH 05/11] Create mcp-retry-contract.test.js --- tests/mcp-retry-contract.test.js | 132 +++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/mcp-retry-contract.test.js 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); +}); From d6d95f99428ba74f0213fcd9953dbc968ae8a9b9 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:05:28 +0500 Subject: [PATCH 06/11] Update mcp-server.md --- docs/mcp-server.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 6df5c35..69b9d2d 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -45,15 +45,13 @@ Public search and profile lookup do not require credentials. To use introduction ## Tools -- `search_developers` searches public profiles and can require agent availability or an active self-declared opportunity type. +- `search_developers` searches public profiles and can require agent availability. - `get_developer_profile` returns one public profile. - `request_introduction` creates a pending request for an opted-in developer. - `get_introduction_status` lets the requesting agent poll its request. After acceptance it returns only the developer's public GitHub URL. Private AI profile settings and private contact details are never returned. -Opportunity-aware searches may pass `opportunityType` as `employment`, `contract`, `open-source`, `speaking`, or `mentoring`. Matching profiles return only active public preferences, their expiry, and an explicit match reason. Expired preferences are omitted before MCP filtering. - Public discovery tools provide schema-validated `structuredContent` with canonical profile URLs, match explanations, public evidence, freshness, agent availability, and the methodology disclaimer. JSON text content remains available for older clients. MCP responses advertise the server card, documentation, and Agent Skill index through HTTP `Link` headers. Privacy-safe usage events include only the MCP method, known tool name, outcome, latency, and aggregate result count; prompts and tool arguments are not recorded. @@ -116,6 +114,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. From d62e5bfd6bb0d94bbe8edef017f4b0fbc6c5a2d2 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:05:57 +0500 Subject: [PATCH 07/11] Update mcp.md --- docs-site/agents/mcp.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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. From 12119a34712d944cddd364ef03b2818a3360f4c1 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:06:41 +0500 Subject: [PATCH 08/11] Update api.md --- docs-site/reference/api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From e968c08fdb3d0cd3acdbfeb9fcd49a5bc6eabfaf Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:11:09 +0500 Subject: [PATCH 09/11] Update devglobe-mcp-client.js --- lib/devglobe-mcp-client.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/devglobe-mcp-client.js b/lib/devglobe-mcp-client.js index b951dda..4561691 100644 --- a/lib/devglobe-mcp-client.js +++ b/lib/devglobe-mcp-client.js @@ -27,6 +27,9 @@ function explainMatch(developer, input = {}) { if (input.location && developer.location?.toLowerCase().includes(input.location.toLowerCase())) { reasons.push(`Location matches ${input.location}`); } + if (input.opportunityType && developer.aiProfile?.opportunityPreferences?.types?.includes(input.opportunityType)) { + reasons.push(`Developer is actively open to ${input.opportunityType} opportunities`); + } const query = String(input.query || '').trim(); if (query) reasons.push(`Matched the public search intent: ${query}`); return reasons.length ? reasons : ['Retrieved by GitHub login']; @@ -34,6 +37,7 @@ function explainMatch(developer, input = {}) { function projectDeveloper(developer, origin, input) { const updatedAt = developer.metricsUpdatedAt || null; + const opportunityPreferences = developer.aiProfile?.opportunityPreferences; return { login: developer.login, name: developer.name || developer.login, @@ -49,6 +53,7 @@ function projectDeveloper(developer, origin, input) { status: updatedAt ? 'reported' : 'unknown', }, availableForAgents: developer.aiProfile?.acceptsAgentRequests === true, + ...(opportunityPreferences ? { opportunityPreferences } : {}), methodologyDisclaimer: MCP_METHODOLOGY_DISCLAIMER, }; } @@ -80,7 +85,7 @@ export function createDevGlobeMcpClient({ const origin = normalizeBaseUrl(baseUrl); return { - async searchDevelopers({ query, location, language, availableForAgents = false, limit = 10 }) { + async searchDevelopers({ query, location, language, opportunityType, availableForAgents = false, limit = 10 }) { const searchText = [query, location, language].filter(Boolean).join(' '); const searchUrl = new URL('/api/search', origin); searchUrl.searchParams.set('q', searchText); @@ -98,8 +103,9 @@ export function createDevGlobeMcpClient({ const matchesLocation = !location || developer.location?.toLowerCase().includes(location.toLowerCase()); const matchesLanguage = !language || developer.topLanguage?.toLowerCase() === language.toLowerCase(); const matchesAvailability = !availableForAgents || developer.aiProfile?.acceptsAgentRequests === true; - return matchesLocation && matchesLanguage && matchesAvailability; - }).slice(0, limit).map(developer => projectDeveloper(developer, origin, { query, location, language })); + const matchesOpportunity = !opportunityType || developer.aiProfile?.opportunityPreferences?.types?.includes(opportunityType); + return matchesLocation && matchesLanguage && matchesAvailability && matchesOpportunity; + }).slice(0, limit).map(developer => projectDeveloper(developer, origin, { query, location, language, opportunityType })); }, async getDeveloperProfile(login) { From 7377d59e33c70a1ad6438f2a82db9e1b1e8959f2 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:11:27 +0500 Subject: [PATCH 10/11] Update devglobe-mcp-server.js --- lib/devglobe-mcp-server.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/devglobe-mcp-server.js b/lib/devglobe-mcp-server.js index 8d38960..0ae7791 100644 --- a/lib/devglobe-mcp-server.js +++ b/lib/devglobe-mcp-server.js @@ -1,8 +1,18 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { createDevGlobeMcpClient, MCP_METHODOLOGY_DISCLAIMER } from './devglobe-mcp-client.js'; +import { OPPORTUNITY_TYPES } from './ai-profile.js'; const evidenceSchema = z.object({ label: z.string(), value: z.number() }); +const opportunityPreferencesSchema = z.object({ + enabled: z.literal(true), + types: z.array(z.enum(OPPORTUNITY_TYPES)), + roles: z.array(z.string()), + locations: z.array(z.string()), + workModes: z.array(z.enum(['remote', 'hybrid', 'onsite'])), + expiresAt: z.string().datetime(), + source: z.literal('self-declared'), +}); const developerSchema = z.object({ login: z.string(), name: z.string(), @@ -15,6 +25,7 @@ const developerSchema = z.object({ publicEvidence: z.array(evidenceSchema), dataFreshness: z.object({ updatedAt: z.string().nullable(), status: z.enum(['reported', 'unknown']) }), availableForAgents: z.boolean(), + opportunityPreferences: opportunityPreferencesSchema.optional(), methodologyDisclaimer: z.string(), }); @@ -64,11 +75,12 @@ export function createDevGlobeMcpServer({ client = createDevGlobeMcpClient() } = const server = new McpServer({ name: 'devglobe', version: '1.0.0' }); server.registerTool('search_developers', { - description: 'Search public DevGlobe developer profiles by expertise, location, language, and agent availability.', + description: 'Search public DevGlobe developer profiles by expertise, location, language, agent availability, and active self-declared opportunity intent.', inputSchema: { query: z.string().min(1).max(200).describe('Skills, expertise, name, or other search intent'), location: z.string().max(100).optional(), language: z.string().max(50).optional(), + opportunityType: z.enum(OPPORTUNITY_TYPES).optional(), availableForAgents: z.boolean().default(false), limit: z.number().int().min(1).max(20).default(10), }, From 275a3524c82288721b3fcf7f46abe27a1549d936 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 21 Aug 2026 18:11:49 +0500 Subject: [PATCH 11/11] Update mcp-server.md --- docs/mcp-server.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 69b9d2d..ec4ec27 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -45,13 +45,15 @@ Public search and profile lookup do not require credentials. To use introduction ## Tools -- `search_developers` searches public profiles and can require agent availability. +- `search_developers` searches public profiles and can require agent availability or an active self-declared opportunity type. - `get_developer_profile` returns one public profile. - `request_introduction` creates a pending request for an opted-in developer. - `get_introduction_status` lets the requesting agent poll its request. After acceptance it returns only the developer's public GitHub URL. Private AI profile settings and private contact details are never returned. +Opportunity-aware searches may pass `opportunityType` as `employment`, `contract`, `open-source`, `speaking`, or `mentoring`. Matching profiles return only active public preferences, their expiry, and an explicit match reason. Expired preferences are omitted before MCP filtering. + Public discovery tools provide schema-validated `structuredContent` with canonical profile URLs, match explanations, public evidence, freshness, agent availability, and the methodology disclaimer. JSON text content remains available for older clients. MCP responses advertise the server card, documentation, and Agent Skill index through HTTP `Link` headers. Privacy-safe usage events include only the MCP method, known tool name, outcome, latency, and aggregate result count; prompts and tool arguments are not recorded.