@orboto/mcp 0.206.1 → 0.207.0

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.
package/dist/index.js CHANGED
@@ -62,6 +62,8 @@ async function main() {
62
62
  const { InboxChannel, digestMinutesFromEnv, cliOutdatedNotice, CLI_OUTDATED_NOTICE } = await import('./inbox-channel.js');
63
63
  const { mcpProcessInstance } = await import('./tools/shared.js');
64
64
  const instanceToken = mcpProcessInstance();
65
+ const { declareEnvProjectScope } = await import('./project-scope.js');
66
+ await declareEnvProjectScope(preflightClient, instanceToken);
65
67
  const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
66
68
  inbox.start();
67
69
  const outdated = cliOutdatedNotice(process.env.ORBOTO_MCP_CLI_OUTDATED);
@@ -0,0 +1,28 @@
1
+ import { rememberDeclaredScope } from './session-scope-memory.js';
2
+ /** Must stay equal to ProjectKeyEnv in cli/internal/cmd/project_scope.go (drift test). */
3
+ export const PROJECT_KEY_ENV = 'ORBOTO_PROJECT_KEY';
4
+ 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) {
7
+ const key = raw?.trim();
8
+ if (!key || !PROJECT_KEY.test(key))
9
+ return null;
10
+ return { role: 'worker', projectKeys: [key.toUpperCase()] };
11
+ }
12
+ /** True when the scope names anything at all. */
13
+ export function scopeDeclared(scope) {
14
+ return !!scope && (!!scope.role || (scope.projectKeys?.length ?? 0) > 0 || (scope.ticketKeys?.length ?? 0) > 0);
15
+ }
16
+ /** 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);
19
+ if (!scope)
20
+ return null;
21
+ const current = await client.get('/v1/agent/session', { instanceToken }).catch(() => null);
22
+ if (current && scopeDeclared(current.scope))
23
+ return current;
24
+ const row = await client.post('/v1/agent/heartbeat', { scope }, { instanceToken }).catch(() => null);
25
+ if (row && typeof row.sessionId === 'string')
26
+ rememberDeclaredScope(instanceToken, row.scope ?? scope);
27
+ return row;
28
+ }
@@ -0,0 +1,86 @@
1
+ /** ORB-2224 - the project a session declares from ORBOTO_PROJECT_KEY, and its drift against the CLI that writes it. */
2
+ import { readFileSync } from 'node:fs';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { declareEnvProjectScope, envProjectScope, PROJECT_KEY_ENV, scopeDeclared } from './project-scope.js';
5
+ import { _forgetDeclaredScopes, rememberedScope } from './session-scope-memory.js';
6
+ import { OrbotoClient } from './orboto-client.js';
7
+ import { makeSessionStartHandler } from './tools/session-start.js';
8
+ const read = (relative) => readFileSync(new URL(`../../../${relative}`, import.meta.url), 'utf8');
9
+ function fakeClient(row) {
10
+ const calls = [];
11
+ return {
12
+ calls,
13
+ get: async (path, opts) => { calls.push({ method: 'GET', path, instanceToken: opts?.instanceToken }); if (row instanceof Error)
14
+ throw row; return row; },
15
+ post: async (path, body, opts) => {
16
+ calls.push({ method: 'POST', path, body, instanceToken: opts?.instanceToken });
17
+ return { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: body.scope };
18
+ },
19
+ };
20
+ }
21
+ beforeEach(() => { _forgetDeclaredScopes(); });
22
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
23
+ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
24
+ it('names the same variable the CLI writes into the entry and hands to the session', () => {
25
+ const go = read('cli/internal/cmd/project_scope.go');
26
+ expect(go).toContain(`const ProjectKeyEnv = "${PROJECT_KEY_ENV}"`);
27
+ expect(read('apps/mcp/src/project-scope.ts')).toContain(`process.env.${PROJECT_KEY_ENV}`);
28
+ expect(read('docs/env.md')).toContain(`| \`${PROJECT_KEY_ENV}\` | mcp |`);
29
+ });
30
+ it('reads a worker scope on the upper-cased key and ignores anything that is no key', () => {
31
+ expect(envProjectScope(' acme ')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
32
+ for (const value of [undefined, '', ' ', 'acme widgets', '1ACME', '${ORBOTO_PROJECT_KEY}']) {
33
+ expect(envProjectScope(value)).toBeNull();
34
+ }
35
+ expect(scopeDeclared(null)).toBe(false);
36
+ expect(scopeDeclared({})).toBe(false);
37
+ expect(scopeDeclared({ role: 'worker' })).toBe(true);
38
+ });
39
+ it('declares the project on a row without a scope and remembers it for the channel reconnect', async () => {
40
+ const client = fakeClient({ sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null });
41
+ await declareEnvProjectScope(client, 'agent-abc', 'ACME');
42
+ expect(client.calls).toEqual([
43
+ { method: 'GET', path: '/v1/agent/session', instanceToken: 'agent-abc' },
44
+ { method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] } }, instanceToken: 'agent-abc' },
45
+ ]);
46
+ expect(rememberedScope('agent-abc')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
47
+ });
48
+ it('declares on a token that has no row yet', async () => {
49
+ const client = fakeClient(new Error('404'));
50
+ await declareEnvProjectScope(client, 'agent-new', 'ACME');
51
+ expect(client.calls.map((c) => c.method)).toEqual(['GET', 'POST']);
52
+ });
53
+ it('keeps a scope the session already declares and does nothing without the variable', async () => {
54
+ const kept = fakeClient({ sessionId: 'x', scope: { role: 'integrator', projectKeys: ['ORB'] } });
55
+ await declareEnvProjectScope(kept, 'agent-kept', 'ACME');
56
+ expect(kept.calls.map((c) => c.method)).toEqual(['GET']);
57
+ const none = fakeClient({ sessionId: 'x', scope: null });
58
+ expect(await declareEnvProjectScope(none, 'agent-none', '')).toBeNull();
59
+ expect(none.calls).toEqual([]);
60
+ });
61
+ it('orboto_session_start without a scope declares the environment project on a row that has none', async () => {
62
+ vi.stubEnv(PROJECT_KEY_ENV, 'acme');
63
+ const beats = [];
64
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
65
+ const u = new URL(url.toString());
66
+ let body = {};
67
+ if (u.pathname === '/v1/agent/heartbeat') {
68
+ const sent = init?.body ? JSON.parse(init.body) : {};
69
+ beats.push(sent);
70
+ body = { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent.scope ?? null };
71
+ }
72
+ else if (u.pathname === '/v1/agent/session') {
73
+ body = { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null };
74
+ }
75
+ else if (u.pathname === '/agent-instructions') {
76
+ body = { instructions: 'rules here', rulesHash: 'fixture' };
77
+ }
78
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
79
+ });
80
+ const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
81
+ const res = await makeSessionStartHandler(client)({}, { sessionId: 'scope1' });
82
+ expect(beats).toEqual([{}, { scope: { role: 'worker', projectKeys: ['ACME'] } }]);
83
+ expect(res.content[0].text).toContain('projects ACME');
84
+ expect(rememberedScope('mcp-scope1')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
85
+ });
86
+ });
@@ -16,6 +16,7 @@ import { loadRequiredRules } from '../required-rules.js';
16
16
  import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
17
17
  import { channelStartLines } from '../inbox-channel.js';
18
18
  import { rememberDeclaredScope } from '../session-scope-memory.js';
19
+ import { declareEnvProjectScope, envProjectScope } from '../project-scope.js';
19
20
  import { agentHeadText } from '../agent-head.js';
20
21
  export const sessionStartToolConfig = {
21
22
  title: 'Load the rules you must follow + re-orient',
@@ -175,11 +176,15 @@ export function makeSessionStartHandler(client, opts = {}) {
175
176
  },
176
177
  };
177
178
  }
178
- const registration = await client.post('/v1/agent/heartbeat', input.scope !== undefined ? { scope: input.scope } : {}, { instanceToken })
179
- .then((r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt } : null))
179
+ const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt } : null);
180
+ let registration = await client.post('/v1/agent/heartbeat', input.scope !== undefined ? { scope: input.scope } : {}, { instanceToken })
181
+ .then(toRegistration)
180
182
  .catch(() => null);
181
183
  if (input.scope !== undefined && registration)
182
184
  rememberDeclaredScope(instanceToken, registration.scope);
185
+ if (input.scope === undefined && registration && !registration.scope && envProjectScope()) {
186
+ registration = toRegistration(await declareEnvProjectScope(client, instanceToken)) ?? registration;
187
+ }
183
188
  const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
184
189
  client.get('/users/me').catch(() => null),
185
190
  loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.206.1",
3
+ "version": "0.207.0",
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",