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
14 changes: 14 additions & 0 deletions .changeset/32641-sys-user-ai-readable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/platform-objects': patch
'@objectstack/mcp': patch
'@object-ui/console': patch
---

feat(platform-objects,mcp): a member's agent finds a colleague by name or email over MCP, to fill a person field

Clause-②: yes (widening) — the MCP read tools (`list_objects`, `describe_object`, `query_records`, `get_record`, `aggregate_records`) now admit one more system object, `sys_user`, which they refused before with `Object "sys_user" is a system object and is not exposed via MCP`. No write tool opens it.
Metadata-protocol: none

- **`sys_user` declares `enable.aiReadable: true`.** An agent reads users with the existing record tools, under every gate a console read runs, and is served exactly what the caller's console person picker is served: their own row and the members of their active organization (the `sys_user_self` and `sys_user_org_members` row scopes of `member_default`), never another organization's users; and every field except the `Admin` group (sign-in trail, lockout, ban reason and expiry, sign-in phone, legacy role, AI seat), which the non-admin permission sets withhold on results and refuse on filters. For a plain member that is: `id`, `name`, `email`, `email_verified`, `two_factor_enabled`, `banned`, `image`, `locale`, `manager_id`, `primary_business_unit_id`, `principal_kind`, `accountable_user_id`, `source`, `created_at`, `updated_at`. An administrator's agent reads what the administrator reads.
- **Read only.** `create_record`, `update_record`, `delete_record` and `delete_records` keep refusing every `sys_*` object, so the self-service profile edit stays a console and data-API affordance; account changes an agent may make go through actions that declare their own `ai.exposed`.
- **What an agent is told.** `query_records`' description and the served `SKILL.md` (and its Console "Download skill" copy) say how to find a colleague's id for a person field: `where: { "email": "..." }` for exactly one person, or `{ "name": { "$contains": "..." } }`; names repeat, so people who share a name are told apart by email. The `list_objects` line no longer says every system object is hidden.
9 changes: 9 additions & 0 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@ can reach is the **default exposure policy (v1)**:
list the approvals waiting on you with `pending_approvers` contains
`{current_user_id}` — answered for a slot that names you by email, position
or team too, not only by user id.
`sys_user` declares it too, so an agent can fill a person field (an owner, an
approver, an employee): it finds a colleague with `query_records` on
`sys_user`, by `email` for exactly one person or by `name` (`$contains`), and
gets their id. It reads exactly what your console's person picker shows you:
your own row and your organization's members, never another organization's
users, with the `Admin` field group (sign-in trail, lockout, ban reason,
sign-in phone) withheld, on results and on filters. Names repeat, so where
several people share one, the agent tells them apart by email. No write tool
opens it.
- **Actions require the author's opt-in**: `ai: { exposed: true }` plus an
`ai.description` of at least 40 characters, and the action must be callable
without a UI. In the open framework that means three types — `script`
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ apply):

```typescript
// Object data
'list_objects' // List objects (sys_* hidden unless declared enable.aiReadable)
'list_objects' // List objects (sys_* hidden unless declared enable.aiReadable: sys_approval_request, sys_user)
'describe_object' // Object schema: fields + features
'validate_expression' // Check a CEL expression against a schema before authoring it
'query_records' // Filter / sort / paginate
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2612,7 +2612,14 @@ export function registerObjectTools(
// participant read alone also serves requests the caller submitted
// or already decided, so an agent must not guess "mine" from it.
'For example, where the app runs approvals, the ones waiting on you: objectName "sys_approval_request", where ' +
'{"status":"pending","pending_approvers":{"$contains":"{current_user_id}"}}; decide one with run_action.',
'{"status":"pending","pending_approvers":{"$contains":"{current_user_id}"}}; decide one with run_action. ' +
// [ruling 6844187236, B] A person field (owner, approver, employee)
// holds a user id: the agent finds it on `sys_user`, which serves
// the caller the colleagues their console person picker does.
// Names repeat (ruling 6867150851), so the text says to tell
// namesakes apart by email.
'To fill a person field, find the colleague\'s id: objectName "sys_user", where {"email":"..."} for exactly ' +
'one person, or {"name":{"$contains":"..."}}; names repeat, so tell people who share a name apart by email.',
inputSchema: strictToolInput(
{
surface: 'this query_records call',
Expand Down
16 changes: 16 additions & 0 deletions packages/mcp/src/skill-md.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,19 @@ describe('renderSkillMarkdown is mirrored by the Console download', () => {
expect(renderObjectStackSkill(options)).toBe(renderSkillMarkdown(options));
});
});

describe('[ruling 6844187236, B] the served skill teaches an agent to find a colleague for a person field', () => {
it('names sys_user as the read-only lookup, by email or by name, and says namesakes are told apart by email', () => {
const md = renderSkillMarkdown();
const at = md.indexOf('**People are `sys_user` records.**');
expect(at, 'the conventions bullet is served').toBeGreaterThan(md.indexOf('## Conventions & gotchas'));
const bullet = md.slice(at, md.indexOf('\n- ', at + 1));
expect(bullet).toContain('query_records');
expect(bullet).toContain('"email"');
expect(bullet).toContain('$contains');
expect(bullet).toContain('tell them apart by email');
expect(bullet).toContain('read only');
// The tools line no longer claims every system object is hidden.
expect(md).not.toContain('(system `sys_*` objects are hidden)');
});
});
10 changes: 9 additions & 1 deletion packages/mcp/src/skill-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,8 @@ create/update payload.

## Tools

- **list_objects()** — list available objects (system \`sys_*\` objects are hidden).
- **list_objects()** — list available objects. System \`sys_*\` objects are hidden,
except the few declared readable by agents, such as \`sys_user\` (read only).
- **describe_object({ objectName })** — an object's fields and features.
- **validate_expression({ objectName, expression, site?, severity?, triggerKind?, sweepObject? })** — check a CEL
expression against an object's schema *before* you author it into a formula,
Expand Down Expand Up @@ -483,6 +484,13 @@ ${renderFileToolLines(options)}${renderDatasetToolLines(options)}${options.feder
- **Writes are real and immediate.** There is no implicit dry-run. Confirm
destructive actions (\`delete_record\`, bulk updates) with the user.
- **Page large reads.** Use \`limit\`/\`offset\` rather than asking for everything.
- **People are \`sys_user\` records.** A person field (owner, approver,
employee) holds a user id. Find it with \`query_records\` on \`sys_user\`:
\`where: { "email": "..." }\` for exactly one person, or
\`{ "name": { "$contains": "..." } }\`. Names repeat: when several people
share a name, tell them apart by email, and ask your human which one if
unsure. You see the colleagues your console's person picker shows you (your
organization, directory fields), and \`sys_user\` is read only.
- **Prefer actions over hand-edits.** When \`list_actions\` offers an action for
the task (approve, convert, close, …), call it instead of updating the
records yourself — actions carry the app's validation and side effects.
Expand Down
28 changes: 28 additions & 0 deletions packages/platform-objects/src/identity/sys-user-ai-read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ruling 6844187236, B] The user table opts its records into the AI read
* tools, so a member's agent can find a colleague to fill a person field.
*
* What an agent is served is decided by the gates every read runs (row-level
* security, field security), not by this declaration; those are pinned end to
* end in `@objectstack/dogfood`'s `colleague-lookup-over-mcp.dogfood.test.ts`.
* This file pins the declaration and what it leaves closed.
*/

import { describe, expect, it } from 'vitest';
import { isAiReadExposed } from '@objectstack/spec/data';
import { SysUser } from './sys-user.object.js';

describe('[ruling B] sys_user AI read declaration', () => {
it('declares read-only AI exposure, read through the one reading the MCP bridges ask', () => {
expect(SysUser.enable?.aiReadable).toBe(true);
expect(isAiReadExposed(SysUser)).toBe(true);
});

it('opens no generic create or delete: the declaration changes no write surface', () => {
// The one generic write on the table stays the self-service profile edit
// (ADR-0092 D4); no MCP write tool admits a sys_* object either way.
expect(SysUser.enable?.apiMethods).toEqual(['get', 'list', 'update', 'bulk']);
});
});
12 changes: 12 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,18 @@ export const SysUser = ObjectSchema.create({
// `update` write, only bulk-update is admitted (createMany/deleteMany still
// 405). `bulk` is a primitive — a permanent, legitimate declaration.
apiMethods: ['get', 'list', 'update', 'bulk'],
// [ruling 6844187236, B] A member's agent finds a colleague by name or
// email with the MCP record tools (list, describe, query, get one,
// aggregate), to fill a person field (an owner, an approver, an employee).
// It reads exactly what that member's console person picker reads, because
// the same gates decide both: the rows are the member's own and their
// organization's (`sys_user_self`, `sys_user_org_members` in
// plugin-security's `member_default`), and the `Admin` field group is
// withheld from every non-admin set (`identityAdminFieldSecurity`, #21237).
// Read only: no MCP write tool opens a `sys_*` object, so the profile
// edit above stays a console and data-API affordance; an account change an
// agent may make goes through an action that declares its own `ai.exposed`.
aiReadable: true,
},

// Email uniqueness is enforced by the unique index above (and better-auth's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ describe('an approver\'s agent lists the approvals waiting on them over MCP (#32
expect(listed.isError, listed.text).toBe(false);
const names = (listed.payload.objects as any[]).map((o) => o.name);
expect(names).toContain(REQUEST);
expect(names.filter((n) => /^sys_/i.test(n))).toEqual([REQUEST]);
// `sys_user` declares the same read opt-in (a member's agent finds a
// colleague for a person field); no other system object is listed.
expect(names.filter((n) => /^sys_/i.test(n)).sort()).toEqual([REQUEST, 'sys_user'].sort());

const described = await tool({ bearer: approver }, 'describe_object', { objectName: REQUEST });
expect(described.isError, described.text).toBe(false);
Expand Down
Loading
Loading