Skip to content
Merged
52 changes: 35 additions & 17 deletions app/api/agent/introductions/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import {
AgentRequestValidationError,
authenticateAgent,
computeRetryAfterSeconds,
createIntroductionDocument,
normalizeIntroductionRequest,
parseAgentKeys,
Expand All @@ -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;
Expand All @@ -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);
}
}

Expand All @@ -76,25 +86,25 @@ 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;
try {
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 {
Expand All @@ -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);
Expand All @@ -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);
}
}
22 changes: 21 additions & 1 deletion docs-site/agents/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
The hosted endpoint intentionally does not create server-side MCP sessions; `GET` and `DELETE` session operations are unsupported.
3 changes: 2 additions & 1 deletion docs-site/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
27 changes: 27 additions & 0 deletions docs/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions lib/agent-introductions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];

Expand Down
15 changes: 14 additions & 1 deletion lib/devglobe-mcp-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
37 changes: 27 additions & 10 deletions lib/devglobe-mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
},
}) }],
};
}
Expand Down Expand Up @@ -157,4 +174,4 @@ export function createDevGlobeMcpServer({ client = createDevGlobeMcpClient() } =
});

return server;
}
}
Loading