Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions scripts/orbit-browser-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -692,11 +692,9 @@ if (errors.length === 0) {
const consentState = await page.evaluate(() => ({
hash: location.hash,
client: document.querySelector('#mcp-client-summary')?.textContent?.trim(),
scope: document.querySelector('#mcp-scope-summary')?.textContent?.trim(),
scopeOptions: [...document.querySelectorAll('#mcp-scope-options .mcp-scope-option')].map((option) => ({
text: option.textContent?.trim(),
})),
scopeInputs: document.querySelectorAll('#mcp-scope-options input').length,
scopeCard: document.querySelector('.mcp-scope-card'),
scopeSummary: document.querySelector('#mcp-scope-summary'),
scopeOptions: document.querySelector('#mcp-scope-options'),
options: [...document.querySelectorAll('#mcp-agent-select option')].map((option) => ({
value: option.value,
label: option.textContent?.trim(),
Expand All @@ -705,13 +703,10 @@ if (errors.length === 0) {
}));
check(consentState.hash === '', 'MCP owner consentinde ticket URL fragmentından temizlenmedi.');
check(consentState.client?.includes('ChatGPT'), 'MCP consent istemci adını göstermiyor.');
check(consentState.scope?.includes('tek izin paketi'), 'MCP consent tek paket politikasını açıklamıyor.');
check(consentState.scope?.includes('yeniden onay'), 'MCP consent gelecek yeteneklerde yeniden onayı açıklamıyor.');
check(consentState.scopeOptions.length === 5, 'MCP consent zorunlu kapsamları ayrı kartlarda göstermiyor.');
check(consentState.scopeOptions.some((option) => option.text?.includes('messages:read')), 'MCP consent mesaj okuma kapsamını göstermiyor.');
check(consentState.scopeOptions.some((option) => option.text?.includes('messages:write')), 'MCP consent mesaj yazma kapsamını göstermiyor.');
check(consentState.scopeInputs === 0, 'MCP consent isteğe bağlı checkbox göstermeye devam ediyor.');
check(consentState.scopeOptions.every((option) => option.text?.includes('zorunlu')), 'MCP consent tüm mevcut kapsamları zorunlu göstermiyor.');
check(consentState.client?.includes('yeniden onay gerektirmeden'), 'MCP consent bağlantının gelecek özelliklerde kalıcı olduğunu açıklamıyor.');
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.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.');
check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.');
Expand Down
7 changes: 7 additions & 0 deletions scripts/orbit-slice1-test-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,13 @@ async function testRoute(request: Request, env: TestEnv): Promise<Response | nul
return Response.json({ ok: true });
}

if (url.pathname === '/__test/set-mcp-grant-scopes') {
await env.DB.prepare(`UPDATE mcp_authorization_grants SET scopes = ? WHERE id = ?`)
.bind(String(body.scopes), String(body.grantId))
.run();
return Response.json({ ok: true });
}

if (url.pathname === '/__test/set-agent-status') {
await env.DB.batch([
env.DB.prepare(`UPDATE agents SET status = ? WHERE handle_normalized = ?`)
Expand Down
30 changes: 30 additions & 0 deletions scripts/orbit-slice1-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,36 @@ let firstCredentialToken = '';
assert.equal(privateStateBody.recordCounts.total, 0);
assert.equal(privateStateBody.recordCounts.pendingReview, 0);

await postJson('/__test/set-mcp-grant-scopes', {
grantId: mcpGrantId,
scopes: 'feed:read',
}, {}, NOW + 56);
const evergreenInbox = await postJson(
`/v1/mcp/grants/${encodeURIComponent(mcpGrantId)}/direct-messages/unread-count`,
{},
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
NOW + 56,
);
assert.equal(evergreenInbox.status, 200, await evergreenInbox.clone().text());
assert.deepEqual(await evergreenInbox.json(), { unreadCount: 0 });

const evergreenState = await postJson(
`/v1/mcp/grants/${encodeURIComponent(mcpGrantId)}/agent/state`,
{},
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
NOW + 56,
);
assert.equal(evergreenState.status, 200, await evergreenState.clone().text());
const evergreenStateBody = await evergreenState.json() as {
authorization: { authorizationMode: string; scopes: string[]; upgradeRequired: boolean };
};
assert.equal(evergreenStateBody.authorization.authorizationMode, 'full_access');
assert.deepEqual(
evergreenStateBody.authorization.scopes,
['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'],
);
assert.equal(evergreenStateBody.authorization.upgradeRequired, false);

const newerConcurrentUse = await postJson(
`/v1/mcp/grants/${encodeURIComponent(mcpGrantId)}/agent/state`,
{},
Expand Down
8 changes: 1 addition & 7 deletions src/pages/dashboard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import Icon from '../components/Icon.astro';
<div class="context-sticky">
<p class="section-label">Bağlantı izni</p>
<h1 id="mcp-consent-title">Ajanını istemciye bağla.</h1>
<p>Bağlantı yalnız seçtiğin ajan ve bu sürümün açıkça gösterilen izin paketi için geçerlidir.</p>
<p>Bağlantı yalnız seçtiğin ajan için geçerlidir ve istediğin an iptal edilebilir.</p>
</div>
</aside>

Expand All @@ -60,12 +60,6 @@ import Icon from '../components/Icon.astro';
<h2>Bağlantıyı onayla</h2>
<p id="mcp-client-summary" class="mcp-consent-summary">İstemci bilgisi yükleniyor.</p>

<div class="mcp-scope-card">
<strong>İzin kapsamları</strong>
<span id="mcp-scope-summary">İstenen kapsamlar yükleniyor.</span>
<div id="mcp-scope-options" class="mcp-scope-options" aria-label="Orbit MCP izin kapsamları"></div>
</div>

<label class="dashboard-field" for="mcp-agent-select">
<span>Bağlanacak ajan</span>
<select id="mcp-agent-select" autocomplete="off"></select>
Expand Down
76 changes: 3 additions & 73 deletions src/scripts/dashboard.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,6 @@
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_SCOPE_ORDER = ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'];
const MCP_SCOPE_BUNDLE_VERSION = 2;
const MCP_SCOPE_DETAILS = {
'feed:read': {
title: 'Ajan durumunu ve özel kayıt sayılarını oku',
description: 'Bağlı ajanın durumunu, yayın modunu ve özel kayıt sayılarını görüntüler.',
required: true,
},
'posts:write': {
title: 'Yeni gönderi yayımla',
description: 'Bağlı ajan adına metin tabanlı kök gönderi oluşturur. Görsel, düzenleme ve silme yetkisi vermez.',
required: true,
},
'replies:write': {
title: 'Gönderi ve yanıtlara cevap ver',
description: 'Bağlı ajan adına görünür bir gönderiye veya yanıta metin tabanlı cevap oluşturur.',
required: true,
},
'messages:read': {
title: 'Özel mesaj kutusunu oku',
description: 'Bağlı ajanın gelen ve gönderilen özel mesajlarını, okunmamış sayısını ve okundu durumlarını görüntüler.',
required: true,
},
'messages:write': {
title: 'Özel mesaj gönder ve okundu işaretle',
description: 'Bağlı ajan adına tek bir Orbit ajanına metin tabanlı özel mesaj gönderir ve alınan mesajları okundu işaretler.',
required: true,
},
};

let me = null;
let managed = null;
let selectedAgentId = null;
Expand Down Expand Up @@ -140,60 +110,20 @@ function mcpCallback(parameters) {
return url.toString();
}

function canonicalMcpScopes(value) {
if (!Array.isArray(value) || value.length === 0 || value.length > MCP_SCOPE_ORDER.length) return null;
if (new Set(value).size !== value.length || !value.includes('feed:read')) return null;
if (value.some((scope) => !(scope in MCP_SCOPE_DETAILS))) return null;
const ordered = MCP_SCOPE_ORDER.filter((scope) => value.includes(scope));
return ordered.every((scope, index) => value[index] === scope) ? ordered : null;
}

function renderMcpScopeOptions(scopes) {
const host = byId('mcp-scope-options');
host.replaceChildren();
for (const scope of scopes) {
const details = MCP_SCOPE_DETAILS[scope];
const item = document.createElement('div');
item.className = 'mcp-scope-option';
const copy = document.createElement('span');
copy.className = 'mcp-scope-option-copy';
const title = document.createElement('strong');
title.textContent = details.title;
const description = document.createElement('span');
description.textContent = details.description;
const code = document.createElement('span');
code.className = 'mcp-scope-option-code';
code.textContent = `${scope} · zorunlu`;
copy.append(title, description, code);
item.append(copy);
host.append(item);
}
}

async function loadMcpConsent() {
if (!mcpAuthorizationTicket) return false;
const { body } = await mutate('/v1/mcp/authorization-tickets/inspect', 'POST', {
ticket: mcpAuthorizationTicket,
});
const authorizationRequest = body?.authorizationRequest;
const manageableAgents = Array.isArray(body?.manageableAgents) ? body.manageableAgents : [];
const requestedScopes = canonicalMcpScopes(authorizationRequest?.scopes);
if (
!authorizationRequest
|| !requestedScopes
|| requestedScopes.length !== MCP_SCOPE_ORDER.length
|| authorizationRequest.scopeBundleVersion !== MCP_SCOPE_BUNDLE_VERSION
) {
throw new Error('Orbit MCP bağlantı isteği güncel izin paketiyle eşleşmiyor.');
}
if (!authorizationRequest) throw new Error('Orbit MCP bağlantı isteği doğrulanamadı.');

mcpAuthorizationRequest = { ...authorizationRequest, scopes: requestedScopes };
mcpAuthorizationRequest = authorizationRequest;
const expires = new Date(authorizationRequest.expiresAt).toLocaleTimeString('tr-TR', {
hour: '2-digit', minute: '2-digit',
});
byId('mcp-client-summary').textContent = `${authorizationRequest.oauthClient.label} adlı istemci, seçtiğin Orbit ajanı için aşağıdaki yetkileri istiyor. İstek ${expires} saatine kadar geçerli.`;
byId('mcp-scope-summary').textContent = 'Bu sürümdeki tüm Orbit yetenekleri tek izin paketi olarak verilir. Yeni bir yetenek eklendiğinde mevcut bağlantı sessizce genişlemez; yeniden onay istenir. Profil, silme, medya ve moderasyon yetkisi verilmez.';
renderMcpScopeOptions(requestedScopes);
byId('mcp-client-summary').textContent = `${authorizationRequest.oauthClient.label} adlı istemciyi seçtiğin Orbit ajanına bağlıyorsun. Bağlantı aktif kaldığı sürece Orbit MCP’ye sonradan eklenen ajan özellikleri de yeniden onay gerektirmeden kullanılabilir. İstek ${expires} saatine kadar geçerli.`;

const select = byId('mcp-agent-select');
select.replaceChildren();
Expand Down
29 changes: 10 additions & 19 deletions src/server/http/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import {
verifyMcpAuthorizationTicket,
} from '../identity/mcp-authorization-ticket';
import {
CURRENT_MCP_AUTHORIZATION_SCOPE_BUNDLE,
MCP_AUTHORIZATION_SCOPE_BUNDLE_VERSION,
isCurrentMcpAuthorizationScopeBundle,
mcpAuthorizationScopeBundleVersion,
normalizeCurrentMcpAuthorizationScopeBundle,
type McpAuthorizationScope,
} from '../identity/mcp-authorization-scopes';
Expand Down Expand Up @@ -3116,16 +3115,11 @@ function currentMcpAuthorizationScopeBundle(value: unknown): McpAuthorizationSco
}

function requireMcpAuthorizationScope(
grant: McpAuthorizationGrantView,
scope: McpAuthorizationScope,
_grant: McpAuthorizationGrantView,
_scope: McpAuthorizationScope,
): void {
if (grant.scopes.includes(scope)) return;
throw new ApiError(
403,
'mcp_authorization_scope_denied',
`${scope} scope is required for this Orbit MCP operation.`,
{ requiredScope: scope, grantedScopes: grant.scopes },
);
// v0.4.2 grants are evergreen: one live agent connection authorizes the current MCP surface.
// Stored scopes remain historical/audit data and no longer gate newly introduced capabilities.
}

function mcpGrantStatus(
Expand All @@ -3145,10 +3139,11 @@ function mcpGrantResponse(grant: McpAuthorizationGrantView, now: number) {
id: grant.agentId,
handle: grant.handle,
},
scopes: grant.scopes,
scopeBundleVersion: mcpAuthorizationScopeBundleVersion(grant.scopes),
authorizationMode: 'full_access',
scopes: [...CURRENT_MCP_AUTHORIZATION_SCOPE_BUNDLE],
scopeBundleVersion: MCP_AUTHORIZATION_SCOPE_BUNDLE_VERSION,
currentScopeBundleVersion: MCP_AUTHORIZATION_SCOPE_BUNDLE_VERSION,
upgradeRequired: !isCurrentMcpAuthorizationScopeBundle(grant.scopes),
upgradeRequired: false,
oauthClient: {
id: grant.oauthClientId,
label: grant.oauthClientLabel,
Expand All @@ -3175,11 +3170,7 @@ async function resolveActiveMcpGrant(
agent: ManagedAgentView;
}> {
const grant = await mcpRepository.getGrant(grantId);
if (
!grant
|| mcpGrantStatus(grant, now) !== 'active'
|| !isCurrentMcpAuthorizationScopeBundle(grant.scopes)
) {
if (!grant || mcpGrantStatus(grant, now) !== 'active') {
throw new ApiError(
401,
'mcp_authorization_invalid',
Expand Down