diff --git a/scripts/orbit-browser-tests.mjs b/scripts/orbit-browser-tests.mjs
index 7ebc145..d31f62a 100644
--- a/scripts/orbit-browser-tests.mjs
+++ b/scripts/orbit-browser-tests.mjs
@@ -217,6 +217,7 @@ if (errors.length === 0) {
status: 'active',
onboardingState: 'active',
})),
+ agentCreation: { available: true, onboardingTtlMs: 60 * 60 * 1000 },
} : { error: { code: 'authentication_required', message: 'A valid session is required.' } }));
}).catch(() => {
response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' });
@@ -707,8 +708,12 @@ if (errors.length === 0) {
check(consentState.scopeCard === null, 'MCP consent izin kartını göstermeye devam ediyor.');
check(consentState.scopeSummary === null, 'MCP consent izin özeti alanını göstermeye devam ediyor.');
check(consentState.scopeOptions === null, 'MCP consent izin seçeneklerini göstermeye devam ediyor.');
- check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.');
+ check(consentState.options.length === browserAgents.length + 1, 'MCP consent mevcut ajanlarla yeni ajan seçeneğini eksik gösteriyor.');
check(consentState.options.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.');
+ check(
+ consentState.options.some((option) => option.value === '__create_new_orbit_agent__' && option.label === 'Yeni bir Orbit ajanı kaydet'),
+ 'MCP consent yeni Orbit ajanı oluşturma seçeneğini sunmuyor.',
+ );
check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.');
await page.locator('#mcp-agent-select').selectOption('agent-selene');
@@ -731,6 +736,42 @@ if (errors.length === 0) {
await context.close();
}
+ {
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: 'light' });
+ await context.addCookies([{
+ name: 'orbit-owner-test',
+ value: '1',
+ url: baseUrl,
+ }]);
+ await context.route('https://mcp.orbit.sametbasbug.dev/**', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'text/html; charset=utf-8',
+ body: '
Orbit MCP callback',
+ });
+ });
+ const page = await context.newPage();
+ const pageErrors = [];
+ page.on('pageerror', (error) => pageErrors.push(error.message));
+ await page.goto(
+ `${baseUrl}/dashboard#mcp_authorization=${encodeURIComponent(browserMcpTicket)}`,
+ { waitUntil: 'load' },
+ );
+ await page.waitForSelector('#mcp-consent:not(.hidden)');
+ await page.locator('#mcp-agent-select').selectOption('__create_new_orbit_agent__');
+ await page.locator('#mcp-approve').click();
+ await page.waitForURL((url) => (
+ url.origin === 'https://mcp.orbit.sametbasbug.dev'
+ && url.pathname === '/oauth/orbit/callback'
+ ));
+ check(browserMcpAuthorizationBodies.length === 2, 'MCP yeni ajan consent isteğini tam bir kez göndermedi.');
+ check(browserMcpAuthorizationBodies[1]?.createAgent === true, 'MCP yeni ajan seçimini createAgent olarak göndermedi.');
+ check(!Object.hasOwn(browserMcpAuthorizationBodies[1] ?? {}, 'agentId'), 'MCP yeni ajan seçimi mevcut agentId göndermemeli.');
+ check(browserMcpAuthorizationBodies[1]?.ticket === browserMcpTicket, 'MCP yeni ajan seçimi imzalı ticketı taşımadı.');
+ check(pageErrors.length === 0, `MCP yeni ajan consentinde sayfa hatası: ${pageErrors.join(' | ')}`);
+ await context.close();
+ }
+
for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) {
const label = `${viewport.width}x${viewport.height} owner`;
const context = await browser.newContext({ viewport, colorScheme: 'light' });
diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts
index 1851498..f6f3acf 100644
--- a/scripts/orbit-slice1-tests.ts
+++ b/scripts/orbit-slice1-tests.ts
@@ -1408,6 +1408,137 @@ let firstCredentialToken = '';
assert.equal(delegated.status, 404);
});
+ test('a first-time MCP connection can create and complete its own Orbit agent without an API credential', async () => {
+ const requestId = 'mcp-native-onboarding-request';
+ const ticketResponse = await postJson('/v1/mcp/authorization-tickets', {
+ authorizationRequestId: requestId,
+ oauthClientId: 'chatgpt-native-onboarding-client',
+ oauthClientLabel: 'ChatGPT',
+ scopes: ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'],
+ scopeBundleVersion: 2,
+ }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 55);
+ assert.equal(ticketResponse.status, 201, await ticketResponse.clone().text());
+ const ticket = (await ticketResponse.json() as { ticket: string }).ticket;
+
+ const inspected = await postJson('/v1/mcp/authorization-tickets/inspect', {
+ ticket,
+ }, { cookie: cookieHeader(otherSponsorCookies) }, NOW + 55);
+ assert.equal(inspected.status, 200, await inspected.clone().text());
+ const inspectedBody = await inspected.json() as {
+ manageableAgents: Array<{ id: string }>;
+ agentCreation: { available: boolean; onboardingTtlMs: number };
+ };
+ assert.deepEqual(inspectedBody.manageableAgents, []);
+ assert.equal(inspectedBody.agentCreation.available, true);
+ assert.equal(inspectedBody.agentCreation.onboardingTtlMs, 60 * 60 * 1000);
+
+ const created = await postJson('/v1/mcp/authorizations', {
+ createAgent: true,
+ ticket,
+ }, authenticatedHeaders(otherSponsorCookies, true), NOW + 56);
+ assert.equal(created.status, 201, await created.clone().text());
+ const createdBody = await created.json() as {
+ authorization: { id: string; agent: { id: string; handle: string }; status: string };
+ delegation: { code: string; authorizationRequestId: string };
+ };
+ assert.equal(createdBody.authorization.status, 'active');
+ assert.match(createdBody.authorization.agent.handle, /^mcp-pending-/u);
+ const onboardingGrantId = createdBody.authorization.id;
+ const onboardingAgentId = createdBody.authorization.agent.id;
+
+ const redeemed = await postJson('/v1/mcp/delegations/redeem', {
+ code: createdBody.delegation.code,
+ authorizationRequestId: requestId,
+ }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 57);
+ assert.equal(redeemed.status, 200, await redeemed.clone().text());
+
+ const pendingState = await postJson(
+ `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/state`,
+ {},
+ { authorization: `Bearer ${MCP_SERVICE_SECRET}` },
+ NOW + 58,
+ );
+ assert.equal(pendingState.status, 200, await pendingState.clone().text());
+ const pendingStateBody = await pendingState.json() as {
+ authorization: { id: string };
+ agent: { id: string; handle: string | null; onboardingState: string; onboardingExpiresAt: number | null };
+ };
+ assert.equal(pendingStateBody.authorization.id, onboardingGrantId);
+ assert.equal(pendingStateBody.agent.id, onboardingAgentId);
+ assert.equal(pendingStateBody.agent.handle, null);
+ assert.equal(pendingStateBody.agent.onboardingState, 'pending');
+ assert.equal(pendingStateBody.agent.onboardingExpiresAt, NOW + 56 + 60 * 60 * 1000);
+
+ const tooEarly = await postJson(
+ `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/records`,
+ { bodyMarkdown: 'Not yet', projectSlug: null, topicSlugs: [], mediaId: null },
+ {
+ authorization: `Bearer ${MCP_SERVICE_SECRET}`,
+ 'idempotency-key': 'mcp-onboarding-too-early',
+ },
+ NOW + 59,
+ );
+ assert.equal(tooEarly.status, 401);
+ const tooEarlyBody = await tooEarly.json() as { error: { code: string } };
+ assert.equal(tooEarlyBody.error.code, 'mcp_agent_onboarding_incomplete');
+
+ const completed = await postJson(
+ `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/onboarding/complete`,
+ { handle: 'web-nova', bio: 'ChatGPT Web üzerinden Orbit’e katıldım.' },
+ { authorization: `Bearer ${MCP_SERVICE_SECRET}` },
+ NOW + 60,
+ );
+ assert.equal(completed.status, 200, await completed.clone().text());
+ const completedBody = await completed.json() as {
+ authorization: { id: string; agent: { id: string; handle: string } };
+ agent: { handle: string; onboardingState: string };
+ };
+ assert.equal(completedBody.authorization.id, onboardingGrantId);
+ assert.equal(completedBody.authorization.agent.id, onboardingAgentId);
+ assert.equal(completedBody.authorization.agent.handle, 'web-nova');
+ assert.equal(completedBody.agent.handle, 'web-nova');
+ assert.equal(completedBody.agent.onboardingState, 'active');
+
+ const replay = await postJson(
+ `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/onboarding/complete`,
+ { handle: 'web-nova', bio: 'ChatGPT Web üzerinden Orbit’e katıldım.' },
+ { authorization: `Bearer ${MCP_SERVICE_SECRET}` },
+ NOW + 61,
+ );
+ assert.equal(replay.status, 200, await replay.clone().text());
+
+ const conflictingReplay = await postJson(
+ `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/onboarding/complete`,
+ { handle: 'web-nova-two', bio: 'Different identity.' },
+ { authorization: `Bearer ${MCP_SERVICE_SECRET}` },
+ NOW + 62,
+ );
+ assert.equal(conflictingReplay.status, 409);
+
+ const activeState = await postJson(
+ `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/state`,
+ {},
+ { authorization: `Bearer ${MCP_SERVICE_SECRET}` },
+ NOW + 63,
+ );
+ assert.equal(activeState.status, 200, await activeState.clone().text());
+ const activeStateBody = await activeState.json() as {
+ authorization: { id: string };
+ agent: { handle: string; onboardingState: string; onboardingExpiresAt: null };
+ };
+ assert.equal(activeStateBody.authorization.id, onboardingGrantId);
+ assert.equal(activeStateBody.agent.handle, 'web-nova');
+ assert.equal(activeStateBody.agent.onboardingState, 'active');
+ assert.equal(activeStateBody.agent.onboardingExpiresAt, null);
+
+ const managed = await request(`/v1/agents/${encodeURIComponent(onboardingAgentId)}/manage`, {
+ headers: authenticatedHeaders(otherSponsorCookies),
+ }, NOW + 64);
+ assert.equal(managed.status, 200, await managed.clone().text());
+ const managedBody = await managed.json() as { agent: { activeCredential: unknown } };
+ assert.equal(managedBody.agent.activeCredential, null);
+ });
+
test('only platform owner can apply all three publication policies', async () => {
const sponsorAttempt = await patchJson(`/v1/admin/agents/${sponsoredAgentId}/policy`, {
publicationMode: 'direct_publish',
diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro
index 2f4d83b..d69f382 100644
--- a/src/pages/dashboard.astro
+++ b/src/pages/dashboard.astro
@@ -64,7 +64,8 @@ import Icon from '../components/Icon.astro';
Bağlanacak ajan
- Bağlanabilecek aktif bir ajan bulunamadı.
+ Yeni ajan seçersen Orbit yalnız geçici bir bağlantı kaydı oluşturur; handle ve bio’yu istemciye döndükten sonra ajanın kendisi tamamlar.
+ Bağlanabilecek aktif bir ajan yok ve yeni ajan kotan dolu.
diff --git a/src/scripts/dashboard.js b/src/scripts/dashboard.js
index 85b0890..d68e8d8 100644
--- a/src/scripts/dashboard.js
+++ b/src/scripts/dashboard.js
@@ -1,6 +1,7 @@
const byId = (id) => document.getElementById(id);
const MCP_TICKET_STORAGE_KEY = 'orbit_mcp_authorization_ticket_v1';
const MCP_CALLBACK_URL = 'https://mcp.orbit.sametbasbug.dev/oauth/orbit/callback';
+const MCP_CREATE_AGENT_VALUE = '__create_new_orbit_agent__';
let me = null;
let managed = null;
let selectedAgentId = null;
@@ -117,6 +118,7 @@ async function loadMcpConsent() {
});
const authorizationRequest = body?.authorizationRequest;
const manageableAgents = Array.isArray(body?.manageableAgents) ? body.manageableAgents : [];
+ const agentCreationAvailable = body?.agentCreation?.available === true;
if (!authorizationRequest) throw new Error('Orbit MCP bağlantı isteği doğrulanamadı.');
mcpAuthorizationRequest = authorizationRequest;
@@ -133,7 +135,14 @@ async function loadMcpConsent() {
option.textContent = `@${agent.handle}${agent.displayName && agent.displayName !== agent.handle ? ` — ${agent.displayName}` : ''}`;
select.append(option);
}
- const empty = manageableAgents.length === 0;
+ if (agentCreationAvailable) {
+ const option = document.createElement('option');
+ option.value = MCP_CREATE_AGENT_VALUE;
+ option.textContent = 'Yeni bir Orbit ajanı kaydet';
+ select.append(option);
+ if (manageableAgents.length === 0) select.value = MCP_CREATE_AGENT_VALUE;
+ }
+ const empty = manageableAgents.length === 0 && !agentCreationAvailable;
byId('mcp-agent-empty').classList.toggle('hidden', !empty);
byId('mcp-approve').disabled = empty;
showPrimaryView('mcp-consent');
@@ -149,10 +158,9 @@ async function approveMcpAuthorization() {
approve.disabled = true;
deny.disabled = true;
try {
- const { body } = await mutate('/v1/mcp/authorizations', 'POST', {
- agentId,
- ticket: mcpAuthorizationTicket,
- });
+ const { body } = await mutate('/v1/mcp/authorizations', 'POST', agentId === MCP_CREATE_AGENT_VALUE
+ ? { createAgent: true, ticket: mcpAuthorizationTicket }
+ : { agentId, ticket: mcpAuthorizationTicket });
const delegation = body?.delegation;
if (
!delegation?.code
diff --git a/src/server/http/api.ts b/src/server/http/api.ts
index ba79f73..211f564 100644
--- a/src/server/http/api.ts
+++ b/src/server/http/api.ts
@@ -163,6 +163,8 @@ const REGISTRATION_CODE_TTL_MS = 10 * 60 * 1000;
const MCP_AUTHORIZATION_TICKET_TTL_MS = 10 * 60 * 1000;
const MCP_DELEGATION_CODE_TTL_MS = 5 * 60 * 1000;
const MCP_AUTHORIZATION_GRANT_TTL_MS = 90 * 24 * 60 * 60 * 1000;
+const MCP_NATIVE_ONBOARDING_TTL_MS = 60 * 60 * 1000;
+const MCP_PENDING_HANDLE_PREFIX = 'mcp-pending-';
class ApiError extends Error {
readonly status: number;
@@ -655,7 +657,7 @@ function decodeOptionalUploadHeader(request: Request, name: string, maximumLengt
function normalizeAgentHandle(value: unknown): string {
const handle = requiredString(value, 'handle', 32).toLowerCase();
- if (!/^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/u.test(handle)) {
+ if (!/^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/u.test(handle) || handle.startsWith(MCP_PENDING_HANDLE_PREFIX)) {
throw new ApiError(400, 'invalid_agent_handle', 'Agent handle must be 3–32 lowercase ASCII characters.');
}
return handle;
@@ -3299,6 +3301,57 @@ function mcpGrantResponse(grant: McpAuthorizationGrantView, now: number) {
};
}
+function isMcpPendingAgent(agent: AgentProfileView): boolean {
+ return agent.status === 'active'
+ && agent.onboardingState === 'pending'
+ && agent.handle.startsWith(MCP_PENDING_HANDLE_PREFIX);
+}
+
+function canCreateSponsoredAgent(account: AccountView, agents: AgentProfileView[], now: number): boolean {
+ if (account.agentQuota < 0) return true;
+ const activeReservations = agents.filter((agent) => (
+ agent.status !== 'retired'
+ && !(isMcpPendingAgent(agent) && agent.createdAt + MCP_NATIVE_ONBOARDING_TTL_MS <= now)
+ )).length;
+ return activeReservations < account.agentQuota;
+}
+
+async function cleanupAbandonedMcpOnboarding(
+ account: AccountView,
+ agentRepository: AgentRepository,
+ mcpRepository: McpAuthorizationRepository,
+ now: number,
+ requestId: string,
+): Promise
{
+ const abandoned = await mcpRepository.listAbandonedPendingGrants({
+ accountId: account.id,
+ createdBefore: now - MCP_NATIVE_ONBOARDING_TTL_MS,
+ });
+ for (const item of abandoned) {
+ const retired = await agentRepository.retirePendingMcpAgent({
+ agentId: item.agentId,
+ sponsorAccountId: account.id,
+ auditEventId: createEntityId(),
+ requestId,
+ now,
+ });
+ if (!retired) continue;
+ try {
+ await mcpRepository.revokeGrant({
+ grantId: item.grantId,
+ actorAccountId: account.id,
+ reason: 'onboarding_expired',
+ auditEventId: createEntityId(),
+ requestId,
+ revokedAt: now,
+ });
+ } catch {
+ const grant = await mcpRepository.getGrant(item.grantId);
+ if (grant?.revokedAt === null) throw new Error('mcp_onboarding_cleanup_failed');
+ }
+ }
+}
+
async function resolveActiveMcpGrant(
grantId: string,
identityRepository: IdentityRepository,
@@ -3306,6 +3359,7 @@ async function resolveActiveMcpGrant(
mcpRepository: McpAuthorizationRepository,
now: number,
touch: boolean,
+ allowPendingOnboarding = false,
): Promise<{
grant: McpAuthorizationGrantView;
account: AccountView;
@@ -3323,17 +3377,30 @@ async function resolveActiveMcpGrant(
}
const account = await identityRepository.getAccount(grant.accountId);
const agent = await agentRepository.getManagedAgent(grant.agentId);
+ const pendingOnboarding = Boolean(
+ agent?.onboardingState === 'pending'
+ && agent.handle.startsWith(MCP_PENDING_HANDLE_PREFIX)
+ );
+ const pendingExpired = pendingOnboarding
+ && grant.createdAt + MCP_NATIVE_ONBOARDING_TTL_MS <= now;
+ const pendingAllowed = allowPendingOnboarding && pendingOnboarding && !pendingExpired;
if (
!account
|| !agent
|| !accountCanManageAgent(account, agent)
|| agent.status !== 'active'
- || agent.onboardingState !== 'active'
+ || (agent.onboardingState !== 'active' && !pendingAllowed)
) {
throw new ApiError(
401,
- 'mcp_authorization_invalid',
- 'The Orbit MCP authorization no longer has access to this agent.',
+ pendingOnboarding
+ ? pendingExpired ? 'mcp_agent_onboarding_expired' : 'mcp_agent_onboarding_incomplete'
+ : 'mcp_authorization_invalid',
+ pendingOnboarding
+ ? pendingExpired
+ ? 'The Orbit MCP agent onboarding window expired. Start a new connection.'
+ : 'Complete Orbit agent registration before using this capability.'
+ : 'The Orbit MCP authorization no longer has access to this agent.',
{},
{ 'www-authenticate': 'Bearer' },
);
@@ -3350,6 +3417,7 @@ async function resolveActiveMcpGrant(
mcpRepository,
now,
false,
+ allowPendingOnboarding,
);
if (refreshed.grant.lastUsedAt === null || refreshed.grant.lastUsedAt < now) {
throw new ApiError(
@@ -3509,6 +3577,7 @@ export async function handleApiRequest(
mcpRepository,
now,
false,
+ true,
);
return json({ authorization: mcpGrantResponse(grant, now) });
}
@@ -3526,6 +3595,7 @@ export async function handleApiRequest(
mcpRepository,
now,
true,
+ true,
);
return json({
authorization: mcpGrantResponse(resolved.grant, now),
@@ -3535,9 +3605,12 @@ export async function handleApiRequest(
},
agent: {
id: resolved.agent.id,
- handle: resolved.agent.handle,
+ handle: resolved.agent.onboardingState === 'pending' ? null : resolved.agent.handle,
status: resolved.agent.status,
onboardingState: resolved.agent.onboardingState,
+ onboardingExpiresAt: resolved.agent.onboardingState === 'pending'
+ ? resolved.grant.createdAt + MCP_NATIVE_ONBOARDING_TTL_MS
+ : null,
publicationMode: resolved.agent.publicationMode,
},
});
@@ -3556,20 +3629,77 @@ export async function handleApiRequest(
mcpRepository,
now,
true,
+ true,
);
return json({
authorization: mcpGrantResponse(resolved.grant, now),
agent: {
id: resolved.agent.id,
- handle: resolved.agent.handle,
+ handle: resolved.agent.onboardingState === 'pending' ? null : resolved.agent.handle,
status: resolved.agent.status,
onboardingState: resolved.agent.onboardingState,
+ onboardingExpiresAt: resolved.agent.onboardingState === 'pending'
+ ? resolved.grant.createdAt + MCP_NATIVE_ONBOARDING_TTL_MS
+ : null,
publicationMode: resolved.agent.publicationMode,
},
recordCounts: await publicationRepository.getAgentRecordCounts(resolved.agent.id),
});
}
+ const mcpCompleteOnboardingMatch = /^\/v1\/mcp\/grants\/([^/]+)\/agent\/onboarding\/complete$/u.exec(path);
+ if (request.method === 'POST' && mcpCompleteOnboardingMatch) {
+ authenticateMcpService(request, env);
+ const body = await readJson(request);
+ requireExactFields(body, ['handle', 'bio'], 'invalid_mcp_agent_onboarding_fields');
+ const grantId = decodeURIComponent(mcpCompleteOnboardingMatch[1]);
+ const resolved = await resolveActiveMcpGrant(
+ grantId,
+ repository,
+ agentRepository,
+ mcpRepository,
+ now,
+ true,
+ true,
+ );
+ const handle = normalizeAgentHandle(body.handle);
+ const bio = requiredString(body.bio, 'bio', 500);
+ let completed: ManagedAgentView;
+ try {
+ completed = await agentRepository.completeMcpOnboarding({
+ agentId: resolved.agent.id,
+ sponsorAccountId: resolved.account.id,
+ handle,
+ bio,
+ auditEventId: createEntityId(),
+ requestId,
+ now,
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '';
+ if (/UNIQUE constraint failed:\s*agents\.handle_normalized\b/iu.test(message)) {
+ throw new ApiError(
+ 409,
+ 'handle_unavailable',
+ 'Bu handle zaten kullanımda; başka bir handle dene.',
+ recoveryDetails(false, 'choose_different_handle', null),
+ );
+ }
+ throw error;
+ }
+ const refreshedGrant = await mcpRepository.getGrant(grantId);
+ if (!refreshedGrant) throw new Error('mcp_authorization_grant_missing_after_onboarding');
+ return json({
+ authorization: mcpGrantResponse(refreshedGrant, now),
+ agent: {
+ handle: completed.handle,
+ status: completed.status,
+ onboardingState: completed.onboardingState,
+ publicationMode: completed.publicationMode,
+ },
+ });
+ }
+
const mcpCreatePostMatch = /^\/v1\/mcp\/grants\/([^/]+)\/records$/u.exec(path);
if (request.method === 'POST' && mcpCreatePostMatch) {
authenticateMcpService(request, env);
@@ -4430,10 +4560,11 @@ export async function handleApiRequest(
'The Orbit MCP authorization ticket is invalid or expired.',
);
}
+ const sponsoredAgents = await agentRepository.listSponsoredAgents(auth.account.id);
const manageableAgents = (
auth.account.roles.includes('platform_owner')
? await agentRepository.listPublicAgents()
- : await agentRepository.listSponsoredAgents(auth.account.id)
+ : sponsoredAgents
).filter((agent) => agent.status === 'active' && agent.onboardingState === 'active');
return json({
authorizationRequest: {
@@ -4456,6 +4587,10 @@ export async function handleApiRequest(
status: agent.status,
onboardingState: agent.onboardingState,
})),
+ agentCreation: {
+ available: canCreateSponsoredAgent(auth.account, sponsoredAgents, now),
+ onboardingTtlMs: MCP_NATIVE_ONBOARDING_TTL_MS,
+ },
});
}
@@ -4470,7 +4605,7 @@ export async function handleApiRequest(
const body = await readJson(request);
requireExactFields(
body,
- ['agentId', 'ticket'],
+ ['agentId', 'createAgent', 'ticket'],
'invalid_mcp_authorization_fields',
);
const ticket = mcpAuthorizationString(body.ticket, 'ticket', 1600);
@@ -4486,18 +4621,31 @@ export async function handleApiRequest(
'The Orbit MCP authorization ticket is invalid or expired.',
);
}
- const agentId = mcpAuthorizationString(body.agentId, 'agentId', 100);
- const agent = requireAgentManagement(
- auth,
- await agentRepository.getManagedAgent(agentId),
- );
- if (agent.status !== 'active' || agent.onboardingState !== 'active') {
+ const createAgent = body.createAgent === true;
+ const hasAgentId = body.agentId !== undefined;
+ if ((createAgent ? 1 : 0) + (hasAgentId ? 1 : 0) !== 1) {
throw new ApiError(
- 409,
- 'mcp_agent_unavailable',
- 'Only active, fully onboarded agents can be authorized for Orbit MCP.',
+ 400,
+ 'invalid_mcp_authorization_target',
+ 'Choose exactly one existing Orbit agent or create one new agent.',
);
}
+
+ let agent: ManagedAgentView | null = null;
+ if (!createAgent) {
+ const agentId = mcpAuthorizationString(body.agentId, 'agentId', 100);
+ agent = requireAgentManagement(
+ auth,
+ await agentRepository.getManagedAgent(agentId),
+ );
+ if (agent.status !== 'active' || agent.onboardingState !== 'active') {
+ throw new ApiError(
+ 409,
+ 'mcp_agent_unavailable',
+ 'Only active, fully onboarded agents can be authorized for Orbit MCP.',
+ );
+ }
+ }
if (authorizationRequest.scopeBundleVersion !== MCP_AUTHORIZATION_SCOPE_BUNDLE_VERSION) {
throw new ApiError(
400,
@@ -4516,30 +4664,62 @@ export async function handleApiRequest(
const grantId = createEntityId();
const codeExpiresAt = now + MCP_DELEGATION_CODE_TTL_MS;
const grantExpiresAt = now + MCP_AUTHORIZATION_GRANT_TTL_MS;
- await mcpRepository.createGrantWithCode({
- grant: {
- id: grantId,
- accountId: auth.account.id,
- agentId: agent.id,
- scopes,
- oauthClientId,
- oauthClientLabel,
- createdAt: now,
- expiresAt: grantExpiresAt,
- },
- code: {
- id: code.selector,
- secretDigest: code.digest,
- hashVersion: code.hashVersion,
- grantId,
- authorizationRequestId,
- createdAt: now,
- expiresAt: codeExpiresAt,
- consumedAt: null,
- },
- auditEventId: createEntityId(),
- requestId,
- });
+ const delegationCode = {
+ id: code.selector,
+ secretDigest: code.digest,
+ hashVersion: code.hashVersion,
+ grantId,
+ authorizationRequestId,
+ createdAt: now,
+ expiresAt: codeExpiresAt,
+ consumedAt: null,
+ };
+ if (createAgent) {
+ await cleanupAbandonedMcpOnboarding(auth.account, agentRepository, mcpRepository, now, requestId);
+ const sponsoredAgents = await agentRepository.listSponsoredAgents(auth.account.id);
+ if (!canCreateSponsoredAgent(auth.account, sponsoredAgents, now)) {
+ throw new ApiError(409, 'agent_quota_exceeded', 'Your Orbit account does not have room for another active or pending agent.');
+ }
+ const pendingAgentId = createEntityId();
+ const pendingHandle = `${MCP_PENDING_HANDLE_PREFIX}${pendingAgentId.replaceAll('-', '').slice(0, 20)}`;
+ await mcpRepository.createPendingAgentGrantWithCode({
+ pendingAgent: { id: pendingAgentId, handle: pendingHandle, createdAt: now },
+ membershipId: createEntityId(),
+ grant: {
+ id: grantId,
+ accountId: auth.account.id,
+ agentId: pendingAgentId,
+ scopes,
+ oauthClientId,
+ oauthClientLabel,
+ createdAt: now,
+ expiresAt: grantExpiresAt,
+ },
+ code: delegationCode,
+ agentAuditEventId: createEntityId(),
+ authorizationAuditEventId: createEntityId(),
+ requestId,
+ });
+ agent = await agentRepository.getManagedAgent(pendingAgentId);
+ if (!agent) throw new Error('mcp_pending_agent_missing_after_creation');
+ } else {
+ if (!agent) throw new Error('mcp_existing_agent_missing');
+ await mcpRepository.createGrantWithCode({
+ grant: {
+ id: grantId,
+ accountId: auth.account.id,
+ agentId: agent.id,
+ scopes,
+ oauthClientId,
+ oauthClientLabel,
+ createdAt: now,
+ expiresAt: grantExpiresAt,
+ },
+ code: delegationCode,
+ auditEventId: createEntityId(),
+ requestId,
+ });
+ }
const grant = await mcpRepository.getGrant(grantId);
if (!grant) throw new Error('mcp_authorization_grant_missing_after_creation');
return json({
@@ -4580,6 +4760,15 @@ export async function handleApiRequest(
requestId,
revokedAt: now,
});
+ if (agent && isMcpPendingAgent(agent)) {
+ await agentRepository.retirePendingMcpAgent({
+ agentId: agent.id,
+ sponsorAccountId: grant.accountId,
+ auditEventId: createEntityId(),
+ requestId,
+ now,
+ });
+ }
const revoked = await mcpRepository.getGrant(grantId);
if (!revoked) throw new Error('mcp_authorization_grant_missing_after_revocation');
return json({ authorization: mcpGrantResponse(revoked, now) });
@@ -4953,6 +5142,14 @@ export async function handleApiRequest(
requestId,
), 400);
}
+ if (/mcp_onboarding_(?:already_complete|state_conflict|agent_not_manageable)/u.test(message)) {
+ return json(createErrorEnvelope(
+ 'mcp_agent_onboarding_state_conflict',
+ 'The Orbit MCP agent onboarding state changed before the request completed.',
+ requestId,
+ recoveryDetails(false, 'refetch_resource', null),
+ ), 409);
+ }
if (/mcp_authorization_(?:grant_unavailable|agent_not_manageable|revoke_forbidden|grant_identity_immutable)|mcp_delegation_code_identity_immutable|UNIQUE constraint failed:\s*mcp_/iu.test(message)) {
return json(createErrorEnvelope(
'mcp_authorization_state_conflict',
diff --git a/src/server/repositories/agent-repository.ts b/src/server/repositories/agent-repository.ts
index 05d6196..91160ed 100644
--- a/src/server/repositories/agent-repository.ts
+++ b/src/server/repositories/agent-repository.ts
@@ -123,6 +123,22 @@ export interface AgentRepository {
auditEventId: string;
requestId: string;
}): Promise;
+ completeMcpOnboarding(input: {
+ agentId: string;
+ sponsorAccountId: string;
+ handle: string;
+ bio: string;
+ auditEventId: string;
+ requestId: string;
+ now: number;
+ }): Promise;
+ retirePendingMcpAgent(input: {
+ agentId: string;
+ sponsorAccountId: string;
+ auditEventId: string;
+ requestId: string;
+ now: number;
+ }): Promise;
updateOwnProfile(input: {
agentId: string;
credentialId: string;
diff --git a/src/server/repositories/d1/d1-agent-repository.ts b/src/server/repositories/d1/d1-agent-repository.ts
index bb3af58..dcfc983 100644
--- a/src/server/repositories/d1/d1-agent-repository.ts
+++ b/src/server/repositories/d1/d1-agent-repository.ts
@@ -6,7 +6,7 @@ import type {
PublicAgentProfileView,
PublicationMode,
} from '../agent-repository';
-import type { D1DatabaseLike } from './d1-foundation-repository';
+import type { D1DatabaseLike, D1RunResultLike } from './d1-foundation-repository';
interface AgentSqlRow {
id: string;
@@ -474,6 +474,113 @@ export class D1AgentRepository implements AgentRepository {
]);
}
+ async completeMcpOnboarding(
+ input: Parameters[0],
+ ): Promise {
+ const before = await this.getManagedAgent(input.agentId);
+ if (!before || before.primarySponsorAccountId !== input.sponsorAccountId) {
+ throw new Error('mcp_onboarding_agent_not_manageable');
+ }
+ if (before.onboardingState === 'active') {
+ if (before.handle === input.handle && before.bio === input.bio) return before;
+ throw new Error('mcp_onboarding_already_complete');
+ }
+
+ await this.#db.batch([
+ this.#db.prepare(`
+ UPDATE agents
+ SET handle = ?, handle_normalized = ?, display_name = ?, bio = ?,
+ onboarding_state = 'active', onboarding_completed_at = ?,
+ updated_at = ?, version = version + 1
+ WHERE id = ?
+ AND status = 'active'
+ AND onboarding_state = 'pending'
+ AND handle_normalized LIKE 'mcp-pending-%'
+ AND EXISTS (
+ SELECT 1 FROM agent_memberships membership
+ WHERE membership.agent_id = agents.id
+ AND membership.account_id = ?
+ AND membership.role = 'primary_sponsor'
+ AND membership.revoked_at IS NULL
+ )
+ `).bind(
+ input.handle,
+ input.handle,
+ input.handle,
+ input.bio,
+ input.now,
+ input.now,
+ input.agentId,
+ input.sponsorAccountId,
+ ),
+ this.#db.prepare(`
+ INSERT INTO audit_events (
+ id, event_type, actor_type, actor_id, subject_type,
+ subject_id, request_id, metadata_json, created_at
+ )
+ SELECT ?, 'agent.mcp_onboarding_completed', 'agent', ?, 'agent', ?, ?, ?, ?
+ WHERE EXISTS (
+ SELECT 1 FROM agents
+ WHERE id = ? AND onboarding_state = 'active'
+ AND handle_normalized = ? AND bio = ? AND updated_at = ?
+ )
+ `).bind(
+ input.auditEventId,
+ input.agentId,
+ input.agentId,
+ input.requestId,
+ auditMetadata({ handle: input.handle, sponsorAccountId: input.sponsorAccountId }),
+ input.now,
+ input.agentId,
+ input.handle,
+ input.bio,
+ input.now,
+ ),
+ ]);
+
+ const completed = await this.getManagedAgent(input.agentId);
+ if (!completed || completed.onboardingState !== 'active' || completed.handle !== input.handle || completed.bio !== input.bio) {
+ throw new Error('mcp_onboarding_state_conflict');
+ }
+ return completed;
+ }
+
+ async retirePendingMcpAgent(
+ input: Parameters[0],
+ ): Promise {
+ const result = await this.#db.prepare(`
+ UPDATE agents
+ SET status = 'retired', updated_at = ?, version = version + 1
+ WHERE id = ?
+ AND status = 'active'
+ AND onboarding_state = 'pending'
+ AND handle_normalized LIKE 'mcp-pending-%'
+ AND EXISTS (
+ SELECT 1 FROM agent_memberships membership
+ WHERE membership.agent_id = agents.id
+ AND membership.account_id = ?
+ AND membership.role = 'primary_sponsor'
+ AND membership.revoked_at IS NULL
+ )
+ `).bind(input.now, input.agentId, input.sponsorAccountId).run();
+ const changed = (result.meta?.changes ?? 0) === 1;
+ if (changed) {
+ await this.#db.prepare(`
+ INSERT INTO audit_events (
+ id, event_type, actor_type, actor_id, subject_type,
+ subject_id, request_id, metadata_json, created_at
+ ) VALUES (?, 'agent.mcp_onboarding_abandoned', 'account', ?, 'agent', ?, ?, '{}', ?)
+ `).bind(
+ input.auditEventId,
+ input.sponsorAccountId,
+ input.agentId,
+ input.requestId,
+ input.now,
+ ).run();
+ }
+ return changed;
+ }
+
async updateOwnProfile(input: Parameters[0]): Promise {
await this.#db.batch([
this.#db.prepare(`
diff --git a/src/server/repositories/d1/d1-mcp-authorization-repository.ts b/src/server/repositories/d1/d1-mcp-authorization-repository.ts
index 720dfc9..81b36ed 100644
--- a/src/server/repositories/d1/d1-mcp-authorization-repository.ts
+++ b/src/server/repositories/d1/d1-mcp-authorization-repository.ts
@@ -158,6 +158,132 @@ export class D1McpAuthorizationRepository implements McpAuthorizationRepository
]);
}
+ async createPendingAgentGrantWithCode(
+ input: Parameters[0],
+ ): Promise {
+ if (input.code.grantId !== input.grant.id || input.pendingAgent.id !== input.grant.agentId) {
+ throw new Error('mcp_pending_agent_grant_mismatch');
+ }
+ const scopes = serializeScopes(input.grant.scopes);
+ await this.#db.batch([
+ this.#db.prepare(`
+ INSERT INTO agents (
+ id, handle, handle_normalized, display_name, bio, avatar_asset,
+ publication_mode, status, onboarding_state, onboarding_completed_at,
+ created_at, updated_at, version,
+ role, short_bio, motto, accent, responsibility, links_json
+ ) VALUES (?, ?, ?, ?, '', '', 'approval_required', 'active', 'pending', NULL, ?, ?, 1,
+ '', '', '', '#6f63e8', '', '[]')
+ `).bind(
+ input.pendingAgent.id,
+ input.pendingAgent.handle,
+ input.pendingAgent.handle,
+ input.pendingAgent.handle,
+ input.pendingAgent.createdAt,
+ input.pendingAgent.createdAt,
+ ),
+ this.#db.prepare(`
+ INSERT INTO agent_memberships (
+ id, agent_id, account_id, role, created_by_account_id, created_at
+ ) VALUES (?, ?, ?, 'primary_sponsor', ?, ?)
+ `).bind(
+ input.membershipId,
+ input.pendingAgent.id,
+ input.grant.accountId,
+ input.grant.accountId,
+ input.pendingAgent.createdAt,
+ ),
+ this.#db.prepare(`
+ INSERT INTO mcp_authorization_grants (
+ id, account_id, agent_id, scopes, oauth_client_id,
+ oauth_client_label, created_at, expires_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).bind(
+ input.grant.id,
+ input.grant.accountId,
+ input.grant.agentId,
+ scopes,
+ input.grant.oauthClientId.trim(),
+ input.grant.oauthClientLabel.trim(),
+ input.grant.createdAt,
+ input.grant.expiresAt,
+ ),
+ this.#db.prepare(`
+ INSERT INTO mcp_delegation_codes (
+ id, secret_digest, hash_version, grant_id,
+ authorization_request_id, created_at, expires_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).bind(
+ input.code.id,
+ input.code.secretDigest,
+ input.code.hashVersion,
+ input.code.grantId,
+ input.code.authorizationRequestId,
+ input.code.createdAt,
+ input.code.expiresAt,
+ ),
+ this.#db.prepare(`
+ INSERT INTO audit_events (
+ id, event_type, actor_type, actor_id, subject_type,
+ subject_id, request_id, metadata_json, created_at
+ ) VALUES (?, 'agent.mcp_onboarding_started', 'account', ?, 'agent', ?, ?, ?, ?)
+ `).bind(
+ input.agentAuditEventId,
+ input.grant.accountId,
+ input.pendingAgent.id,
+ input.requestId,
+ auditMetadata({ oauthClientId: input.grant.oauthClientId }),
+ input.pendingAgent.createdAt,
+ ),
+ this.#db.prepare(`
+ INSERT INTO audit_events (
+ id, event_type, actor_type, actor_id, subject_type,
+ subject_id, request_id, metadata_json, created_at
+ ) VALUES (?, 'mcp.authorization_created', 'account', ?,
+ 'mcp_authorization_grant', ?, ?, ?, ?)
+ `).bind(
+ input.authorizationAuditEventId,
+ input.grant.accountId,
+ input.grant.id,
+ input.requestId,
+ auditMetadata({
+ agentId: input.grant.agentId,
+ onboardingState: 'pending',
+ scopes: input.grant.scopes,
+ oauthClientId: input.grant.oauthClientId,
+ oauthClientLabel: input.grant.oauthClientLabel,
+ authorizationRequestId: input.code.authorizationRequestId,
+ }),
+ input.grant.createdAt,
+ ),
+ ]);
+ }
+
+ async listAbandonedPendingGrants(
+ input: Parameters[0],
+ ): Promise> {
+ const result = await this.#db.prepare(`
+ SELECT grant_row.id AS grant_id, agent.id AS agent_id
+ FROM mcp_authorization_grants grant_row
+ JOIN agents agent ON agent.id = grant_row.agent_id
+ JOIN agent_memberships membership ON membership.agent_id = agent.id
+ WHERE grant_row.account_id = ?
+ AND membership.account_id = ?
+ AND membership.role = 'primary_sponsor'
+ AND membership.revoked_at IS NULL
+ AND agent.status = 'active'
+ AND agent.onboarding_state = 'pending'
+ AND agent.handle_normalized LIKE 'mcp-pending-%'
+ AND agent.created_at <= ?
+ AND grant_row.revoked_at IS NULL
+ ORDER BY agent.created_at ASC, agent.id ASC
+ `).bind(input.accountId, input.accountId, input.createdBefore).all<{
+ grant_id: string;
+ agent_id: string;
+ }>();
+ return result.results.map((row) => ({ grantId: row.grant_id, agentId: row.agent_id }));
+ }
+
async getGrant(grantId: string): Promise {
const row = await this.#db.prepare(`
${GRANT_SELECT}
diff --git a/src/server/repositories/mcp-authorization-repository.ts b/src/server/repositories/mcp-authorization-repository.ts
index a02c666..61c6a36 100644
--- a/src/server/repositories/mcp-authorization-repository.ts
+++ b/src/server/repositories/mcp-authorization-repository.ts
@@ -48,6 +48,34 @@ export interface McpAuthorizationRepository {
requestId: string;
}): Promise;
+ createPendingAgentGrantWithCode(input: {
+ pendingAgent: {
+ id: string;
+ handle: string;
+ createdAt: number;
+ };
+ membershipId: string;
+ grant: {
+ id: string;
+ accountId: string;
+ agentId: string;
+ scopes: McpAuthorizationScope[];
+ oauthClientId: string;
+ oauthClientLabel: string;
+ createdAt: number;
+ expiresAt: number | null;
+ };
+ code: McpDelegationCodeView;
+ agentAuditEventId: string;
+ authorizationAuditEventId: string;
+ requestId: string;
+ }): Promise;
+
+ listAbandonedPendingGrants(input: {
+ accountId: string;
+ createdBefore: number;
+ }): Promise>;
+
getGrant(grantId: string): Promise;
listAccountGrants(accountId: string): Promise;