Skip to content

Commit b3ae8a3

Browse files
authored
Bundle Orbit MCP permissions (#36)
## Summary - require the complete current MCP permission bundle (`feed:read`, `posts:write`, `replies:write`) for every new authorization - remove consent checkboxes and client-controlled downscoping - version the bundle so future capabilities require explicit reauthorization instead of silently widening old grants - expose bundle version and upgrade state in grant responses and reject legacy partial grants on delegated calls - keep revocation, sponsor authority, idempotency, media restrictions and granular internal scopes intact ## Validation - `npm run check`: 0 errors, warnings or hints - `npm run test:d1`: 124/124 tests passed - site integrity: 1,879 assertions passed - browser regression: 406 assertions passed - production live assets + Wrangler bundle dry-run passed ## Deployment order Deploy Orbit production first, then deploy the Remote MCP v0.3 single OAuth endpoint immediately afterward.
1 parent 4559c74 commit b3ae8a3

7 files changed

Lines changed: 130 additions & 128 deletions

File tree

‎scripts/orbit-browser-tests.mjs‎

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ if (errors.length === 0) {
206206
id: browserMcpAuthorizationRequestId,
207207
oauthClient: { id: 'chatgpt-browser-client', label: 'ChatGPT' },
208208
scopes: ['feed:read', 'posts:write', 'replies:write'],
209+
scopeBundleVersion: 1,
209210
issuedAt: Date.now(),
210211
expiresAt: Date.now() + 10 * 60 * 1000,
211212
},
@@ -692,11 +693,10 @@ if (errors.length === 0) {
692693
hash: location.hash,
693694
client: document.querySelector('#mcp-client-summary')?.textContent?.trim(),
694695
scope: document.querySelector('#mcp-scope-summary')?.textContent?.trim(),
695-
scopeOptions: [...document.querySelectorAll('#mcp-scope-options input[data-mcp-scope]')].map((input) => ({
696-
scope: input.dataset.mcpScope,
697-
checked: input.checked,
698-
disabled: input.disabled,
696+
scopeOptions: [...document.querySelectorAll('#mcp-scope-options .mcp-scope-option')].map((option) => ({
697+
text: option.textContent?.trim(),
699698
})),
699+
scopeInputs: document.querySelectorAll('#mcp-scope-options input').length,
700700
options: [...document.querySelectorAll('#mcp-agent-select option')].map((option) => ({
701701
value: option.value,
702702
label: option.textContent?.trim(),
@@ -705,21 +705,16 @@ if (errors.length === 0) {
705705
}));
706706
check(consentState.hash === '', 'MCP owner consentinde ticket URL fragmentından temizlenmedi.');
707707
check(consentState.client?.includes('ChatGPT'), 'MCP consent istemci adını göstermiyor.');
708-
check(consentState.scope?.includes('isteğe bağlı yazma'), 'MCP consent kapsam daraltma açıklamasını göstermiyor.');
709-
check(consentState.scopeOptions.length === 3, 'MCP consent istenen kapsamları ayrı seçenekler olarak göstermiyor.');
710-
const feedScope = consentState.scopeOptions.find((option) => option.scope === 'feed:read');
711-
const postScope = consentState.scopeOptions.find((option) => option.scope === 'posts:write');
712-
const replyScope = consentState.scopeOptions.find((option) => option.scope === 'replies:write');
713-
check(feedScope?.checked === true && feedScope?.disabled === true, 'MCP consent zorunlu feed:read kapsamını sabitlemiyor.');
714-
check(postScope?.checked === false && postScope?.disabled === false, 'MCP consent posts:write kapsamını varsayılan kapalı göstermiyor.');
715-
check(replyScope?.checked === false && replyScope?.disabled === false, 'MCP consent replies:write kapsamını varsayılan kapalı göstermiyor.');
708+
check(consentState.scope?.includes('tek izin paketi'), 'MCP consent tek paket politikasını açıklamıyor.');
709+
check(consentState.scope?.includes('yeniden onay'), 'MCP consent gelecek yeteneklerde yeniden onayı açıklamıyor.');
710+
check(consentState.scopeOptions.length === 3, 'MCP consent zorunlu kapsamları ayrı kartlarda göstermiyor.');
711+
check(consentState.scopeInputs === 0, 'MCP consent isteğe bağlı checkbox göstermeye devam ediyor.');
712+
check(consentState.scopeOptions.every((option) => option.text?.includes('zorunlu')), 'MCP consent tüm mevcut kapsamları zorunlu göstermiyor.');
716713
check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.');
717714
check(consentState.options.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.');
718715
check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.');
719716

720717
await page.locator('#mcp-agent-select').selectOption('agent-selene');
721-
await page.locator('#mcp-scope-options input[data-mcp-scope="posts:write"]').check();
722-
await page.locator('#mcp-scope-options input[data-mcp-scope="replies:write"]').check();
723718
await page.locator('#mcp-approve').click();
724719
await page.waitForURL((url) => (
725720
url.origin === 'https://mcp.orbit.sametbasbug.dev'
@@ -732,8 +727,8 @@ if (errors.length === 0) {
732727
check(browserMcpAuthorizationBodies[0]?.agentId === 'agent-selene', 'MCP consent seçilen ajanı grant isteğine bağlamadı.');
733728
check(browserMcpAuthorizationBodies[0]?.ticket === browserMcpTicket, 'MCP consent imzalı ticketı grant isteğine taşımadı.');
734729
check(
735-
JSON.stringify(browserMcpAuthorizationBodies[0]?.scopes) === JSON.stringify(['feed:read', 'posts:write', 'replies:write']),
736-
'MCP consent yalnız açıkça seçilen kapsamları grant isteğine taşımadı.',
730+
!Object.hasOwn(browserMcpAuthorizationBodies[0] ?? {}, 'scopes'),
731+
'MCP consent istemci kontrollü scopes alanı göndermeye devam ediyor.',
737732
);
738733
check(pageErrors.length === 0, `MCP owner consentinde sayfa hatası: ${pageErrors.join(' | ')}`);
739734
await context.close();

‎scripts/orbit-slice1-tests.ts‎

Lines changed: 32 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,6 @@ let firstCredentialToken = '';
229229

230230
async function createAuthorizedMcpGrant(options: {
231231
requestId: string;
232-
requestedScopes: string[];
233-
grantedScopes: string[];
234232
now: number;
235233
}): Promise<{
236234
grantId: string;
@@ -243,7 +241,8 @@ let firstCredentialToken = '';
243241
authorizationRequestId: options.requestId,
244242
oauthClientId: `chatgpt-${options.requestId}`,
245243
oauthClientLabel: 'ChatGPT',
246-
scopes: options.requestedScopes,
244+
scopes: ['feed:read', 'posts:write', 'replies:write'],
245+
scopeBundleVersion: 1,
247246
},
248247
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
249248
options.now,
@@ -256,7 +255,6 @@ let firstCredentialToken = '';
256255
{
257256
agentId: sponsoredAgentId,
258257
ticket: ticketBody.ticket,
259-
scopes: options.grantedScopes,
260258
},
261259
authenticatedHeaders(sponsorCookies, true),
262260
options.now + 1,
@@ -266,7 +264,10 @@ let firstCredentialToken = '';
266264
authorization: { id: string; scopes: string[] };
267265
delegation: { code: string };
268266
};
269-
assert.deepEqual(body.authorization.scopes, options.grantedScopes);
267+
assert.deepEqual(
268+
body.authorization.scopes,
269+
['feed:read', 'posts:write', 'replies:write'],
270+
);
270271
return {
271272
grantId: body.authorization.id,
272273
delegationCode: body.delegation.code,
@@ -508,12 +509,13 @@ let firstCredentialToken = '';
508509
assert.deepEqual(meBody.sponsoredAgents.map((agent) => agent.id), [sponsoredAgentId]);
509510
});
510511

511-
test('MCP authorization uses CSRF, least privilege, one-time exchange and revocation', async () => {
512+
test('MCP authorization uses CSRF, a versioned permission bundle, one-time exchange and revocation', async () => {
512513
const ticketRequest = {
513514
authorizationRequestId: mcpAuthorizationRequestId,
514515
oauthClientId: 'chatgpt-dynamic-client-001',
515516
oauthClientLabel: 'ChatGPT',
516517
scopes: ['feed:read', 'posts:write', 'replies:write'],
518+
scopeBundleVersion: 1,
517519
};
518520

519521
const unauthenticatedList = await request('/v1/mcp/authorizations', {}, NOW + 47);
@@ -535,7 +537,7 @@ let firstCredentialToken = '';
535537
);
536538
assert.equal(elevated.status, 400);
537539
const elevatedBody = await elevated.json() as { error: { code: string } };
538-
assert.equal(elevatedBody.error.code, 'invalid_mcp_authorization_scope');
540+
assert.equal(elevatedBody.error.code, 'invalid_mcp_authorization_scope_bundle');
539541

540542
const ticketResponse = await postJson(
541543
'/v1/mcp/authorization-tickets',
@@ -550,6 +552,7 @@ let firstCredentialToken = '';
550552
id: string;
551553
oauthClient: { id: string; label: string };
552554
scopes: string[];
555+
scopeBundleVersion: number;
553556
issuedAt: number;
554557
expiresAt: number;
555558
};
@@ -561,6 +564,7 @@ let firstCredentialToken = '';
561564
ticketBody.authorizationRequest.scopes,
562565
['feed:read', 'posts:write', 'replies:write'],
563566
);
567+
assert.equal(ticketBody.authorizationRequest.scopeBundleVersion, 1);
564568
assert.equal(ticketBody.authorizationRequest.issuedAt, NOW + 49);
565569
assert.equal(ticketBody.authorizationRequest.expiresAt, NOW + 49 + 10 * 60 * 1000);
566570

@@ -580,7 +584,12 @@ let firstCredentialToken = '';
580584
);
581585
assert.equal(inspected.status, 200, await inspected.clone().text());
582586
const inspectedBody = await inspected.json() as {
583-
authorizationRequest: { id: string; oauthClient: { label: string }; scopes: string[] };
587+
authorizationRequest: {
588+
id: string;
589+
oauthClient: { label: string };
590+
scopes: string[];
591+
scopeBundleVersion: number;
592+
};
584593
manageableAgents: Array<{
585594
id: string;
586595
handle: string;
@@ -594,6 +603,7 @@ let firstCredentialToken = '';
594603
inspectedBody.authorizationRequest.scopes,
595604
['feed:read', 'posts:write', 'replies:write'],
596605
);
606+
assert.equal(inspectedBody.authorizationRequest.scopeBundleVersion, 1);
597607
assert.deepEqual(
598608
inspectedBody.manageableAgents.map((agent) => agent.id),
599609
[sponsoredAgentId],
@@ -611,36 +621,23 @@ let firstCredentialToken = '';
611621
);
612622
assert.equal(tamperedInspect.status, 400);
613623

614-
const readOnlyTicket = await postJson(
624+
const partialBundleTicket = await postJson(
615625
'/v1/mcp/authorization-tickets',
616626
{
617627
...ticketRequest,
618-
authorizationRequestId: 'chatgpt-read-only-request',
628+
authorizationRequestId: 'chatgpt-partial-bundle-request',
619629
scopes: ['feed:read'],
620630
},
621631
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
622632
NOW + 49,
623633
);
624-
assert.equal(readOnlyTicket.status, 201, await readOnlyTicket.clone().text());
625-
const readOnlyTicketBody = await readOnlyTicket.json() as { ticket: string };
626-
const widenedGrant = await postJson(
627-
'/v1/mcp/authorizations',
628-
{
629-
agentId: sponsoredAgentId,
630-
ticket: readOnlyTicketBody.ticket,
631-
scopes: ['feed:read', 'posts:write'],
632-
},
633-
authenticatedHeaders(sponsorCookies, true),
634-
NOW + 50,
635-
);
636-
assert.equal(widenedGrant.status, 400);
637-
const widenedGrantBody = await widenedGrant.json() as { error: { code: string } };
638-
assert.equal(widenedGrantBody.error.code, 'invalid_mcp_authorization_scope');
634+
assert.equal(partialBundleTicket.status, 400);
635+
const partialBundleBody = await partialBundleTicket.json() as { error: { code: string } };
636+
assert.equal(partialBundleBody.error.code, 'invalid_mcp_authorization_scope_bundle');
639637

640638
const authorizationBody = {
641639
agentId: sponsoredAgentId,
642640
ticket: ticketBody.ticket,
643-
scopes: ['feed:read'],
644641
};
645642
const noCsrf = await postJson(
646643
'/v1/mcp/authorizations',
@@ -686,7 +683,7 @@ let firstCredentialToken = '';
686683
mcpDelegationCode = createdBody.delegation.code;
687684
assert.ok(mcpGrantId);
688685
assert.ok(mcpDelegationCode.startsWith('orb_mcp_v1_'));
689-
assert.deepEqual(createdBody.authorization.scopes, ['feed:read']);
686+
assert.deepEqual(createdBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write']);
690687
assert.equal(createdBody.authorization.agent.id, sponsoredAgentId);
691688
assert.equal(createdBody.authorization.oauthClient.label, 'ChatGPT');
692689
assert.equal(createdBody.authorization.status, 'active');
@@ -730,7 +727,7 @@ let firstCredentialToken = '';
730727
authorization: { id: string; scopes: string[]; status: string };
731728
};
732729
assert.equal(redeemedBody.authorization.id, mcpGrantId);
733-
assert.deepEqual(redeemedBody.authorization.scopes, ['feed:read']);
730+
assert.deepEqual(redeemedBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write']);
734731
assert.equal(redeemedBody.authorization.status, 'active');
735732

736733
const replay = await postJson('/v1/mcp/delegations/redeem', {
@@ -786,7 +783,10 @@ let firstCredentialToken = '';
786783
};
787784
};
788785
assert.equal(privateStateBody.authorization.id, mcpGrantId);
789-
assert.deepEqual(privateStateBody.authorization.scopes, ['feed:read']);
786+
assert.deepEqual(
787+
privateStateBody.authorization.scopes,
788+
['feed:read', 'posts:write', 'replies:write'],
789+
);
790790
assert.equal(privateStateBody.authorization.lastUsedAt, NOW + 56);
791791
assert.equal(privateStateBody.agent.id, sponsoredAgentId);
792792
assert.equal(privateStateBody.agent.handle, 'selene-test-agent');
@@ -865,24 +865,9 @@ let firstCredentialToken = '';
865865
assert.equal(expiredTicket.status, 400);
866866
});
867867

868-
test('MCP delegated writes enforce independent scopes, idempotency and revocation', async () => {
869-
const requestedScopes = ['feed:read', 'posts:write', 'replies:write'];
870-
const postOnly = await createAuthorizedMcpGrant({
871-
requestId: 'chatgpt-post-only-request',
872-
requestedScopes,
873-
grantedScopes: ['feed:read', 'posts:write'],
874-
now: NOW + 1_000,
875-
});
876-
const replyOnly = await createAuthorizedMcpGrant({
877-
requestId: 'chatgpt-reply-only-request',
878-
requestedScopes,
879-
grantedScopes: ['feed:read', 'replies:write'],
880-
now: NOW + 1_010,
881-
});
868+
test('MCP delegated writes enforce the full bundle, idempotency and revocation', async () => {
882869
const full = await createAuthorizedMcpGrant({
883870
requestId: 'chatgpt-write-request',
884-
requestedScopes,
885-
grantedScopes: requestedScopes,
886871
now: NOW + 1_020,
887872
});
888873

@@ -916,26 +901,6 @@ let firstCredentialToken = '';
916901
const missingKeyBody = await missingKey.json() as { error: { code: string } };
917902
assert.equal(missingKeyBody.error.code, 'idempotency_key_required');
918903

919-
const postDenied = await postJson(
920-
`/v1/mcp/grants/${encodeURIComponent(replyOnly.grantId)}/records`,
921-
mcpWritePostBody,
922-
mcpServiceHeaders('mcp-post-scope-denied'),
923-
NOW + 1_032,
924-
);
925-
assert.equal(postDenied.status, 403);
926-
const mcpPostDeniedBody = await postDenied.json() as { error: { code: string } };
927-
assert.equal(mcpPostDeniedBody.error.code, 'mcp_authorization_scope_denied');
928-
929-
const replyDenied = await postJson(
930-
`/v1/mcp/grants/${encodeURIComponent(postOnly.grantId)}/records/not-needed/replies`,
931-
{ bodyMarkdown: 'Bu yanıt kapsam nedeniyle reddedilmeli.' },
932-
mcpServiceHeaders('mcp-reply-scope-denied'),
933-
NOW + 1_033,
934-
);
935-
assert.equal(replyDenied.status, 403);
936-
const replyDeniedBody = await replyDenied.json() as { error: { code: string } };
937-
assert.equal(replyDeniedBody.error.code, 'mcp_authorization_scope_denied');
938-
939904
const mediaDenied = await postJson(
940905
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`,
941906
{ ...mcpWritePostBody, mediaId: '019f64d2-0109-7644-9a4e-a0d25df888e2' },
@@ -1211,7 +1176,8 @@ let firstCredentialToken = '';
12111176
authorizationRequestId: 'foreign-authorization-request',
12121177
oauthClientId: 'foreign-client',
12131178
oauthClientLabel: 'Foreign client',
1214-
scopes: ['feed:read'],
1179+
scopes: ['feed:read', 'posts:write', 'replies:write'],
1180+
scopeBundleVersion: 1,
12151181
}, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 54);
12161182
assert.equal(foreignTicketResponse.status, 201);
12171183
const foreignTicket = (await foreignTicketResponse.json() as { ticket: string }).ticket;

‎src/pages/dashboard.astro‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ import Icon from '../components/Icon.astro';
5050
<aside class="context-rail dashboard-context">
5151
<div class="context-sticky">
5252
<p class="section-label">Bağlantı izni</p>
53-
<h1 id="mcp-consent-title">Ajanını ChatGPT’ye bağla.</h1>
54-
<p>İzin yalnız seçtiğin ajan ve aşağıda açıkça onayladığın kapsamlar için geçerlidir.</p>
53+
<h1 id="mcp-consent-title">Ajanını istemciye bağla.</h1>
54+
<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>
5555
</div>
5656
</aside>
5757

@@ -76,7 +76,7 @@ import Icon from '../components/Icon.astro';
7676
<button id="mcp-approve" class="dashboard-button primary" type="button">Bağlantıya izin ver</button>
7777
<button id="mcp-deny" class="dashboard-button secondary" type="button">Reddet</button>
7878
</div>
79-
<p class="login-assurance"><Icon name="info" size={15} /> Ajanının uzun ömürlü API anahtarı ChatGPT’ye veya MCP sunucusuna verilmez.</p>
79+
<p class="login-assurance"><Icon name="info" size={15} /> Ajanının uzun ömürlü API anahtarı istemciye veya MCP sunucusuna verilmez.</p>
8080
</article>
8181

8282
<aside class="network-rail dashboard-rail">

0 commit comments

Comments
 (0)