@orboto/mcp 0.212.6 → 0.213.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,15 @@
1
1
  import { rememberDeclaredScope } from './session-scope-memory.js';
2
2
  /** Must stay equal to ProjectKeyEnv in cli/internal/cmd/project_scope.go (drift test). */
3
3
  export const PROJECT_KEY_ENV = 'ORBOTO_PROJECT_KEY';
4
+ /** ORB-2448 - must stay equal to SessionRoleEnv in cli/internal/cmd/claude_control_session.go (drift test). */
5
+ export const SESSION_ROLE_ENV = 'ORBOTO_SESSION_ROLE';
6
+ /** ORB-2448 - must stay equal to ControlRoleDefault in the CLI and AGENT_CONTROL_ROLES in shared-schema. */
7
+ export const CONTROL_ROLE = 'coordinator';
4
8
  const PROJECT_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
5
- /** The worker scope on the project the environment names, or null. */
6
- export function envProjectScope(raw = process.env.ORBOTO_PROJECT_KEY) {
9
+ /** The control session the remembered role names (ORB-2448), else the worker scope on the project the environment names, or null. */
10
+ export function envProjectScope(raw = process.env.ORBOTO_PROJECT_KEY, role = process.env.ORBOTO_SESSION_ROLE) {
11
+ if (role?.trim().toLowerCase() === CONTROL_ROLE)
12
+ return { role: CONTROL_ROLE };
7
13
  const key = raw?.trim();
8
14
  if (!key || !PROJECT_KEY.test(key))
9
15
  return null;
@@ -14,8 +20,8 @@ export function scopeDeclared(scope) {
14
20
  return !!scope && (!!scope.role || (scope.projectKeys?.length ?? 0) > 0 || (scope.ticketKeys?.length ?? 0) > 0);
15
21
  }
16
22
  /** Declares the environment's project on the session row unless the row already carries a scope; answers the row. */
17
- export async function declareEnvProjectScope(client, instanceToken, raw = process.env.ORBOTO_PROJECT_KEY) {
18
- const scope = envProjectScope(raw);
23
+ export async function declareEnvProjectScope(client, instanceToken, raw = process.env.ORBOTO_PROJECT_KEY, role = process.env.ORBOTO_SESSION_ROLE) {
24
+ const scope = envProjectScope(raw, role);
19
25
  if (!scope)
20
26
  return null;
21
27
  const current = await client.get('/v1/agent/session', { instanceToken }).catch(() => null);
@@ -1,7 +1,7 @@
1
1
  /** ORB-2224 - the project a session declares from ORBOTO_PROJECT_KEY, and its drift against the CLI that writes it. */
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { declareEnvProjectScope, envProjectScope, PROJECT_KEY_ENV, scopeDeclared } from './project-scope.js';
4
+ import { CONTROL_ROLE, declareEnvProjectScope, envProjectScope, PROJECT_KEY_ENV, SESSION_ROLE_ENV, scopeDeclared } from './project-scope.js';
5
5
  import { _forgetDeclaredScopes, rememberedScope } from './session-scope-memory.js';
6
6
  import { OrbotoClient } from './orboto-client.js';
7
7
  import { makeSessionStartHandler } from './tools/session-start.js';
@@ -84,3 +84,47 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
84
84
  expect(rememberedScope('mcp-scope1')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
85
85
  });
86
86
  });
87
+ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
88
+ it('names the variable and the role the CLI and shared-schema use', () => {
89
+ expect(read('cli/internal/cmd/claude_control_session.go')).toContain(`const SessionRoleEnv = "${SESSION_ROLE_ENV}"`);
90
+ expect(read('cli/internal/cmd/claude_control_session.go')).toContain(`const ControlRoleDefault = "${CONTROL_ROLE}"`);
91
+ expect(read('packages/shared-schema/src/agent-session-scope.ts')).toContain(`AGENT_CONTROL_ROLES: readonly AgentSessionRole[] = ['${CONTROL_ROLE}']`);
92
+ expect(read('apps/mcp/src/project-scope.ts')).toContain(`process.env.${SESSION_ROLE_ENV}`);
93
+ expect(read('docs/env.md')).toContain(`| \`${SESSION_ROLE_ENV}\` | mcp |`);
94
+ });
95
+ it('declares a control session without a project ahead of the project key; any other role is ignored', async () => {
96
+ expect(envProjectScope('ACME', ' Coordinator ')).toEqual({ role: 'coordinator' });
97
+ expect(envProjectScope(undefined, 'coordinator')).toEqual({ role: 'coordinator' });
98
+ expect(envProjectScope('ACME', 'lead')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
99
+ expect(envProjectScope(undefined, 'lead')).toBeNull();
100
+ const client = fakeClient({ sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null });
101
+ await declareEnvProjectScope(client, 'agent-control', undefined, 'coordinator');
102
+ expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' } }, instanceToken: 'agent-control' });
103
+ expect(rememberedScope('agent-control')).toEqual({ role: 'coordinator' });
104
+ });
105
+ it('orboto_session_start without a scope declares the control session the environment names', async () => {
106
+ vi.stubEnv(SESSION_ROLE_ENV, 'coordinator');
107
+ vi.stubEnv(PROJECT_KEY_ENV, 'ACME');
108
+ const beats = [];
109
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
110
+ const u = new URL(url.toString());
111
+ let body = {};
112
+ if (u.pathname === '/v1/agent/heartbeat') {
113
+ const sent = init?.body ? JSON.parse(init.body) : {};
114
+ beats.push(sent);
115
+ body = { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent.scope ?? null };
116
+ }
117
+ else if (u.pathname === '/v1/agent/session') {
118
+ body = { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null };
119
+ }
120
+ else if (u.pathname === '/agent-instructions') {
121
+ body = { instructions: 'rules here', rulesHash: 'fixture' };
122
+ }
123
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
124
+ });
125
+ const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
126
+ await makeSessionStartHandler(client)({}, { sessionId: 'control1' });
127
+ expect(beats).toEqual([{}, { scope: { role: 'coordinator' } }]);
128
+ expect(rememberedScope('mcp-control1')).toEqual({ role: 'coordinator' });
129
+ });
130
+ });
@@ -239,7 +239,7 @@ export const TruncationBlockSchema = z.object({
239
239
  */
240
240
  export const TruncationBlockAdvertisedSchema = z.object({ handle: z.string() })
241
241
  .passthrough()
242
- .describe('Response was cut; pass `handle` to orboto_response_expand.');
242
+ .describe('Response cut; pass `handle` to orboto_response_expand.');
243
243
  function stringMarker(omitted, handle, path) {
244
244
  return `... [truncated ${omitted} chars - full value: orboto_response_expand handle="${handle}" path="${path}"]`;
245
245
  }
@@ -10,7 +10,7 @@ import { mcpInstanceToken } from './shared.js';
10
10
  import { AgentSessionScopeSchema } from './agent-session-scope.js';
11
11
  export const agentHeartbeatToolConfig = {
12
12
  title: 'Agent heartbeat (Multi-Agent Coordination)',
13
- description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it.',
13
+ description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it. `startedBy`: starter session id, same account.',
14
14
  inputSchema: z.object({
15
15
  sessionToken: z.string().nullable().optional(),
16
16
  scope: AgentSessionScopeSchema.optional(),
@@ -23,11 +23,13 @@ export const agentHeartbeatToolConfig = {
23
23
  host: z.string().optional(),
24
24
  user_agent: z.string().optional(),
25
25
  }).optional(),
26
+ startedBy: z.string().uuid().optional(),
26
27
  }).shape,
27
28
  outputSchema: z.object({
28
29
  sessionToken: z.string(),
29
30
  sessionId: z.string().uuid(),
30
31
  scope: AgentSessionScopeSchema.nullable(),
32
+ startedBy: z.string().uuid().nullable().optional(),
31
33
  }).shape,
32
34
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
33
35
  };
@@ -37,7 +39,7 @@ export function makeAgentHeartbeatHandler(client) {
37
39
  const scope = res.scope ? ` scope=${JSON.stringify(res.scope)}` : '';
38
40
  return {
39
41
  content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}… session ${res.sessionId.slice(0, 8)}${scope}` }],
40
- structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId, scope: res.scope ?? null },
42
+ structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId, scope: res.scope ?? null, ...(res.startedBy !== undefined ? { startedBy: res.startedBy } : {}) },
41
43
  };
42
44
  };
43
45
  }
@@ -266,6 +266,17 @@ describe('ORB-2136 - session addressing', () => {
266
266
  expect(seen[0].headers['x-orboto-agent-session']).toBe('mcp-abc123');
267
267
  expect(result.structuredContent).toMatchObject({ scope: { role: 'integrator', projectKeys: ['ORB'] } });
268
268
  });
269
+ it('agent_heartbeat names the starter and answers the recorded one (ORB-2449)', async () => {
270
+ const starter = '5a1c0d2e-7b3f-4c11-9e2a-0c0ffee00001';
271
+ const seen = [];
272
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
273
+ seen.push(JSON.parse(init?.body ?? '{}'));
274
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ sessionToken: 'mcp-abc123', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'worker' }, startedBy: starter }), text: async () => '' };
275
+ });
276
+ const result = await makeAgentHeartbeatHandler(client)({ scope: { role: 'worker' }, startedBy: starter }, { sessionId: 'abc123' });
277
+ expect(seen[0]).toMatchObject({ startedBy: starter });
278
+ expect(result.structuredContent).toMatchObject({ startedBy: starter });
279
+ });
269
280
  });
270
281
  describe('orboto_agent_notify role addresses (ORB-2264)', () => {
271
282
  it('sends `to` as written, lists every recipient and needs no targetEmail', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.212.6",
3
+ "version": "0.213.1",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",