From 3a66ae0f5debd223ae0b70b4842608a7e8b16689 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Sat, 8 Aug 2026 19:36:29 +0300 Subject: [PATCH] Add MCP-native profile management Add live-grant-bound profile reads and ETag-protected profile updates for credentialless MCP-native agents. Keep MCP responses free of internal grant/account/agent identifiers and harden the D1 transition against stale versions, expired or revoked grants, inactive identities, and lost management authority. --- .../0034_mcp_agent_profile_customization.sql | 95 +++++++++++ scripts/orbit-slice1-tests.ts | 74 +++++++++ src/server/http/api.ts | 148 ++++++++++++++++++ src/server/observability/telemetry.ts | 2 + src/server/repositories/agent-repository.ts | 14 ++ .../repositories/d1/d1-agent-repository.ts | 41 +++++ 6 files changed, 374 insertions(+) create mode 100644 migrations/0034_mcp_agent_profile_customization.sql diff --git a/migrations/0034_mcp_agent_profile_customization.sql b/migrations/0034_mcp_agent_profile_customization.sql new file mode 100644 index 0000000..a42beb4 --- /dev/null +++ b/migrations/0034_mcp_agent_profile_customization.sql @@ -0,0 +1,95 @@ +CREATE TABLE mcp_agent_profile_customization_updates ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id), + grant_id TEXT NOT NULL REFERENCES mcp_authorization_grants(id), + expected_version INTEGER NOT NULL CHECK (expected_version > 0), + bio TEXT NOT NULL CHECK (length(trim(bio)) <= 500), + role TEXT NOT NULL CHECK (length(trim(role)) <= 80), + accent TEXT NOT NULL CHECK ( + length(accent) = 7 + AND substr(accent, 1, 1) = '#' + AND substr(accent, 2) NOT GLOB '*[^0-9A-Fa-f]*' + ), + pinned_record_id TEXT, + created_at INTEGER NOT NULL +); + +CREATE TRIGGER mcp_agent_profile_customization_updates_validate +BEFORE INSERT ON mcp_agent_profile_customization_updates +BEGIN + SELECT RAISE(ABORT, 'agent_version_conflict') + WHERE NOT EXISTS ( + SELECT 1 + FROM agents agent + JOIN mcp_authorization_grants grant_row ON grant_row.agent_id = agent.id + JOIN accounts account ON account.id = grant_row.account_id + WHERE agent.id = NEW.agent_id + AND agent.version = NEW.expected_version + AND agent.status = 'active' + AND agent.onboarding_state = 'active' + AND grant_row.id = NEW.grant_id + AND grant_row.revoked_at IS NULL + AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.created_at) + AND account.status = 'active' + AND ( + EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = agent.id + AND membership.account_id = account.id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role_row + WHERE role_row.account_id = account.id + AND role_row.role = 'platform_owner' + AND role_row.revoked_at IS NULL + ) + ) + ); + + SELECT RAISE(ABORT, 'agent_pinned_record_invalid') + WHERE NEW.pinned_record_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM records record + WHERE record.id = NEW.pinned_record_id + AND record.author_agent_id = NEW.agent_id + AND record.kind = 'post' + AND record.lifecycle_state = 'published' + AND record.current_revision_id IS NOT NULL + AND record.pending_revision_id IS NULL + AND record.deleted_at IS NULL + AND record.moderation_state = 'visible' + ); +END; + +CREATE TRIGGER mcp_agent_profile_customization_updates_apply +AFTER INSERT ON mcp_agent_profile_customization_updates +BEGIN + UPDATE agents + SET bio = trim(NEW.bio), + role = trim(NEW.role), + accent = lower(NEW.accent), + pinned_record_id = NEW.pinned_record_id, + updated_at = NEW.created_at, + version = version + 1 + WHERE id = NEW.agent_id AND version = NEW.expected_version; +END; + +CREATE TRIGGER mcp_agent_profile_customization_updates_no_update +BEFORE UPDATE ON mcp_agent_profile_customization_updates +BEGIN + SELECT RAISE(ABORT, 'mcp_agent_profile_customization_updates_are_append_only'); +END; + +CREATE TRIGGER mcp_agent_profile_customization_updates_no_delete +BEFORE DELETE ON mcp_agent_profile_customization_updates +BEGIN + SELECT RAISE(ABORT, 'mcp_agent_profile_customization_updates_are_append_only'); +END; + +CREATE INDEX mcp_agent_profile_customization_updates_agent_idx +ON mcp_agent_profile_customization_updates (agent_id, created_at DESC); diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index 7dc1ed6..0bca062 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -1648,6 +1648,80 @@ let firstCredentialToken = ''; assert.equal(managed.status, 200, await managed.clone().text()); const managedBody = await managed.json() as { agent: { activeCredential: unknown } }; assert.equal(managedBody.agent.activeCredential, null); + + const serviceHeaders = { authorization: `Bearer ${MCP_SERVICE_SECRET}` }; + const profile = await postJson( + `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/profile`, + {}, + serviceHeaders, + NOW + 65, + ); + assert.equal(profile.status, 200, await profile.clone().text()); + const profileText = await profile.text(); + assert.ok(!profileText.includes(onboardingAgentId)); + assert.ok(!profileText.includes(onboardingGrantId)); + const profileBody = JSON.parse(profileText) as { + etag: string; + profile: { + handle: string; + bio: string; + avatarAsset: string | null; + role: string; + accent: string; + pinnedRecordId: string | null; + }; + }; + assert.match(profileBody.etag, /^"profile-v\d+"$/u); + assert.equal(profileBody.profile.handle, 'web-nova'); + assert.equal(profileBody.profile.avatarAsset, null); + + const missingEtag = await postJson( + `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/profile/update`, + { bio: 'MCP profile update without concurrency token.' }, + serviceHeaders, + NOW + 66, + ); + assert.equal(missingEtag.status, 428, await missingEtag.clone().text()); + + const updated = await postJson( + `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/profile/update`, + { + etag: profileBody.etag, + bio: 'MCP profile update test.', + role: 'MCP profile tester', + accent: '#12abef', + }, + serviceHeaders, + NOW + 67, + ); + assert.equal(updated.status, 200, await updated.clone().text()); + const updatedText = await updated.text(); + assert.ok(!updatedText.includes(onboardingAgentId)); + assert.ok(!updatedText.includes(onboardingGrantId)); + const updatedBody = JSON.parse(updatedText) as { + etag: string; + profile: { bio: string; role: string; accent: string }; + }; + assert.notEqual(updatedBody.etag, profileBody.etag); + assert.equal(updatedBody.profile.bio, 'MCP profile update test.'); + assert.equal(updatedBody.profile.role, 'MCP profile tester'); + assert.equal(updatedBody.profile.accent, '#12abef'); + + const stale = await postJson( + `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/profile/update`, + { etag: profileBody.etag, bio: 'This stale write must fail.' }, + serviceHeaders, + NOW + 68, + ); + assert.equal(stale.status, 409, await stale.clone().text()); + const staleText = await stale.text(); + assert.ok(!staleText.includes(onboardingAgentId)); + assert.ok(!staleText.includes(onboardingGrantId)); + const staleBody = JSON.parse(staleText) as { + error: { code: string; details?: { conflict?: { currentEtag?: string } } }; + }; + assert.equal(staleBody.error.code, 'version_conflict'); + assert.equal(staleBody.error.details?.conflict?.currentEtag, updatedBody.etag); }); test('only platform owner can apply all three publication policies', async () => { diff --git a/src/server/http/api.ts b/src/server/http/api.ts index b653e5a..7a1805b 100644 --- a/src/server/http/api.ts +++ b/src/server/http/api.ts @@ -341,6 +341,39 @@ function agentEtag(agent: AgentProfileView): string { return `"agent-${agent.id}-v${agent.version}"`; } +function mcpAgentProfileEtag(agent: AgentProfileView): string { + return `"profile-v${agent.version}"`; +} + +function mcpOwnProfile(agent: AgentProfileView) { + return { + etag: mcpAgentProfileEtag(agent), + profile: { + handle: agent.handle, + bio: agent.bio, + avatarAsset: agent.avatarAsset || null, + role: agent.role, + accent: agent.accent, + pinnedRecordId: agent.pinnedRecordId, + updatedAt: agent.updatedAt, + }, + }; +} + +function mcpProfileVersionConflictError(agent: AgentProfileView | null, now: number): ApiError { + return new ApiError( + 409, + 'version_conflict', + 'Agent profile changed. Refresh and retry.', + recoveryDetails(true, 'refetch_resource', now, { + conflict: { + type: 'version', + currentEtag: agent ? mcpAgentProfileEtag(agent) : null, + }, + }), + ); +} + function versionConflictError(agent: AgentProfileView | null, now: number): ApiError { return new ApiError( 409, @@ -3832,6 +3865,121 @@ export async function handleApiRequest( }); } + const mcpAgentProfileMatch = /^\/v1\/mcp\/grants\/([^/]+)\/agent\/profile$/u.exec(path); + if (request.method === 'POST' && mcpAgentProfileMatch) { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields(body, [], 'invalid_mcp_agent_profile_read_fields'); + const resolved = await resolveActiveMcpGrant( + decodeURIComponent(mcpAgentProfileMatch[1]), + repository, + agentRepository, + mcpRepository, + now, + true, + ); + return json(mcpOwnProfile(resolved.agent)); + } + + const mcpAgentProfileUpdateMatch = /^\/v1\/mcp\/grants\/([^/]+)\/agent\/profile\/update$/u.exec(path); + if (request.method === 'POST' && mcpAgentProfileUpdateMatch) { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields( + body, + ['etag', 'bio', 'role', 'accent', 'pinnedRecordId'], + 'invalid_mcp_agent_profile_fields', + ); + const changedFields = Object.keys(body).filter((field) => field !== 'etag') as Array< + 'bio' | 'role' | 'accent' | 'pinnedRecordId' + >; + if (changedFields.length === 0) { + throw new ApiError(400, 'invalid_agent_profile', 'At least one editable profile field is required.'); + } + const grantId = decodeURIComponent(mcpAgentProfileUpdateMatch[1]); + const resolved = await resolveActiveMcpGrant( + grantId, + repository, + agentRepository, + mcpRepository, + now, + true, + ); + if (typeof body.etag !== 'string' || body.etag.length === 0) { + throw new ApiError( + 428, + 'precondition_required', + 'etag is required for MCP agent profile updates.', + recoveryDetails(true, 'refetch_resource', now, { requiredField: 'etag' }), + ); + } + if (body.etag !== mcpAgentProfileEtag(resolved.agent)) { + throw mcpProfileVersionConflictError(resolved.agent, now); + } + const bio = body.bio === undefined ? resolved.agent.bio : requiredString(body.bio, 'bio', 500); + const role = body.role === undefined ? resolved.agent.role : requiredString(body.role, 'role', 80); + let accent = resolved.agent.accent; + if (body.accent !== undefined) { + if (typeof body.accent !== 'string' || !/^#[0-9a-f]{6}$/iu.test(body.accent.trim())) { + throw new ApiError(400, 'invalid_agent_profile', 'accent must be a six-digit hexadecimal color.'); + } + accent = body.accent.trim().toLowerCase(); + } + let pinnedRecordId = resolved.agent.pinnedRecordId; + if (body.pinnedRecordId !== undefined) { + if ( + body.pinnedRecordId !== null + && (typeof body.pinnedRecordId !== 'string' || body.pinnedRecordId.length > 80) + ) { + throw new ApiError(400, 'invalid_agent_profile', 'pinnedRecordId must be a record ID or null.'); + } + pinnedRecordId = body.pinnedRecordId === null ? null : body.pinnedRecordId; + if (pinnedRecordId !== null) { + const record = await publicationRepository.getRecord(pinnedRecordId); + if ( + !record + || record.authorAgentId !== resolved.agent.id + || record.kind !== 'post' + || record.lifecycleState !== 'published' + || record.currentRevisionId === null + || record.pendingRevisionId !== null + || record.deletedAt !== null + || record.moderationState !== 'visible' + ) { + throw new ApiError(400, 'invalid_pinned_record', 'Only your own visible published post can be pinned.'); + } + } + } + try { + await agentRepository.updateOwnProfileFromMcp({ + agentId: resolved.agent.id, + grantId, + bio, + role, + accent, + pinnedRecordId, + changedFields, + expectedVersion: resolved.agent.version, + transitionId: createEntityId(), + auditEventId: createEntityId(), + requestId, + now, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/agent_version_conflict/u.test(message)) { + throw mcpProfileVersionConflictError(await agentRepository.getManagedAgent(resolved.agent.id), now); + } + if (/agent_pinned_record_invalid/u.test(message)) { + throw new ApiError(400, 'invalid_pinned_record', 'Only your own visible published post can be pinned.'); + } + throw error; + } + const updated = await agentRepository.getManagedAgent(resolved.agent.id); + if (!updated) throw new Error('mcp_agent_profile_update_missing'); + return json(mcpOwnProfile(updated)); + } + const mcpCompleteOnboardingMatch = /^\/v1\/mcp\/grants\/([^/]+)\/agent\/onboarding\/complete$/u.exec(path); if (request.method === 'POST' && mcpCompleteOnboardingMatch) { authenticateMcpService(request, env); diff --git a/src/server/observability/telemetry.ts b/src/server/observability/telemetry.ts index c7c400e..ff3d670 100644 --- a/src/server/observability/telemetry.ts +++ b/src/server/observability/telemetry.ts @@ -19,6 +19,8 @@ function routeName(path: string): string { [/^\/v1\/mcp\/authorizations\/[^/]+\/revoke$/u, '/v1/mcp/authorizations/:grant/revoke'], [/^\/v1\/mcp\/grants\/[^/]+\/resolve$/u, '/v1/mcp/grants/:grant/resolve'], [/^\/v1\/mcp\/grants\/[^/]+\/agent\/state$/u, '/v1/mcp/grants/:grant/agent/state'], + [/^\/v1\/mcp\/grants\/[^/]+\/agent\/profile(?:\/update)?$/u, '/v1/mcp/grants/:grant/agent/profile/:action'], + [/^\/v1\/mcp\/grants\/[^/]+\/agent\/onboarding\/complete$/u, '/v1/mcp/grants/:grant/agent/onboarding/complete'], [/^\/v1\/mcp\/grants\/[^/]+\/direct-messages\/unread-count$/u, '/v1/mcp/grants/:grant/direct-messages/unread-count'], [/^\/v1\/mcp\/grants\/[^/]+\/direct-messages\/list$/u, '/v1/mcp/grants/:grant/direct-messages/list'], [/^\/v1\/mcp\/grants\/[^/]+\/direct-messages\/send$/u, '/v1/mcp/grants/:grant/direct-messages/send'], diff --git a/src/server/repositories/agent-repository.ts b/src/server/repositories/agent-repository.ts index 6bad065..1eb90fe 100644 --- a/src/server/repositories/agent-repository.ts +++ b/src/server/repositories/agent-repository.ts @@ -157,6 +157,20 @@ export interface AgentRepository { requestId: string; now: number; }): Promise; + updateOwnProfileFromMcp(input: { + agentId: string; + grantId: string; + bio: string; + role: string; + accent: string; + pinnedRecordId: string | null; + changedFields: Array<'bio' | 'role' | 'accent' | 'pinnedRecordId'>; + expectedVersion: number; + transitionId: string; + auditEventId: string; + requestId: string; + now: number; + }): Promise; issueFirstCredential(input: { agentId: string; actorAccountId: string; diff --git a/src/server/repositories/d1/d1-agent-repository.ts b/src/server/repositories/d1/d1-agent-repository.ts index 895323e..e6325ad 100644 --- a/src/server/repositories/d1/d1-agent-repository.ts +++ b/src/server/repositories/d1/d1-agent-repository.ts @@ -631,6 +631,47 @@ export class D1AgentRepository implements AgentRepository { ]); } + async updateOwnProfileFromMcp(input: Parameters[0]): Promise { + await this.#db.batch([ + this.#db.prepare(` + INSERT INTO mcp_agent_profile_customization_updates ( + id, agent_id, grant_id, expected_version, + bio, role, accent, pinned_record_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + input.transitionId, + input.agentId, + input.grantId, + input.expectedVersion, + input.bio, + input.role, + input.accent, + input.pinnedRecordId, + input.now, + ), + 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.profile_updated', 'agent', ?, 'agent', ?, ?, ?, ?) + `).bind( + input.auditEventId, + input.agentId, + input.agentId, + input.requestId, + auditMetadata({ + channel: 'mcp', + fields: input.changedFields, + expectedVersion: input.expectedVersion, + ...(input.changedFields.includes('pinnedRecordId') + ? { pinnedRecordId: input.pinnedRecordId } + : {}), + }), + input.now, + ), + ]); + } + async issueFirstCredential(input: Parameters[0]): Promise { await this.#db.batch([ this.#credentialInsert(input.agentId, input.actorAccountId, input.credential),