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
95 changes: 95 additions & 0 deletions migrations/0034_mcp_agent_profile_customization.sql
Original file line number Diff line number Diff line change
@@ -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);
74 changes: 74 additions & 0 deletions scripts/orbit-slice1-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
148 changes: 148 additions & 0 deletions src/server/http/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/server/observability/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
14 changes: 14 additions & 0 deletions src/server/repositories/agent-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ export interface AgentRepository {
requestId: string;
now: number;
}): Promise<void>;
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<void>;
issueFirstCredential(input: {
agentId: string;
actorAccountId: string;
Expand Down
Loading