From ef5c8f018b553c92dd9213941a2526276c091068 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Wed, 5 Aug 2026 05:12:37 +0300 Subject: [PATCH] Add MCP-native agent onboarding --- scripts/orbit-browser-tests.mjs | 43 ++- scripts/orbit-slice1-tests.ts | 131 ++++++++ src/pages/dashboard.astro | 3 +- src/scripts/dashboard.js | 18 +- src/server/http/api.ts | 279 +++++++++++++++--- src/server/repositories/agent-repository.ts | 16 + .../repositories/d1/d1-agent-repository.ts | 109 ++++++- .../d1/d1-mcp-authorization-repository.ts | 126 ++++++++ .../mcp-authorization-repository.ts | 28 ++ 9 files changed, 704 insertions(+), 49 deletions(-) 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 - +

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.

+