@myagentroam/node 0.13.15 → 0.13.16

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.
@@ -5,20 +5,16 @@ export class ExecutionLifecycle {
5
5
  continuous;
6
6
  goal;
7
7
  usage;
8
- goalMcp;
9
- scheduleMcp;
10
- notificationMcp;
8
+ runtime;
11
9
  events;
12
10
  upgrade;
13
- constructor(databaseAvailable, scheduled, continuous, goal, usage, goalMcp, scheduleMcp, notificationMcp, events, upgrade) {
11
+ constructor(databaseAvailable, scheduled, continuous, goal, usage, runtime, events, upgrade) {
14
12
  this.databaseAvailable = databaseAvailable;
15
13
  this.scheduled = scheduled;
16
14
  this.continuous = continuous;
17
15
  this.goal = goal;
18
16
  this.usage = usage;
19
- this.goalMcp = goalMcp;
20
- this.scheduleMcp = scheduleMcp;
21
- this.notificationMcp = notificationMcp;
17
+ this.runtime = runtime;
22
18
  this.events = events;
23
19
  this.upgrade = upgrade;
24
20
  }
@@ -28,9 +24,7 @@ export class ExecutionLifecycle {
28
24
  this.continuous.runTerminated(run.sessionId);
29
25
  }
30
26
  revokeMcps(executionId) {
31
- this.goalMcp.revoke(executionId);
32
- this.scheduleMcp.revoke(executionId);
33
- this.notificationMcp.revoke(executionId);
27
+ this.runtime.revokeExecutionMcps(executionId);
34
28
  }
35
29
  release(executionId) {
36
30
  this.usage.release(executionId);
package/dist/connector.js CHANGED
@@ -296,7 +296,7 @@ export class NodeConnector {
296
296
  commandStates = new CommandStateStore();
297
297
  goalRuntime = new GoalRuntime(() => undefined);
298
298
  sessionScheduleService;
299
- goalMcpBridge = new GoalMcpBridge(this.goalRuntime, (sessionId) => this.goalCoordinator.changed(sessionId));
299
+ goalMcpBridge = new GoalMcpBridge(this.goalRuntime, (sessionId) => this.goalCoordinator.changed(sessionId), (capability) => this.runtime.resolveMcpExecution(capability)?.sessionId);
300
300
  scheduleMcpBridge = new ScheduleMcpBridge(() => {
301
301
  if (this.database === undefined)
302
302
  throw new Error('NODE_DATABASE_UNAVAILABLE');
@@ -308,11 +308,12 @@ export class NodeConnector {
308
308
  session: this.sessionPresentationService.present(session)
309
309
  });
310
310
  this.scheduledSessions.changed();
311
- }, (sessionId, proposalId) => this.scheduledSessions.proposalCreated(sessionId, proposalId), (input) => {
311
+ }, (sessionId, proposalId) => this.scheduledSessions.proposalCreated(sessionId, proposalId), (capability) => this.runtime.resolveMcpExecution({ ...capability, userId: capability.actorUserId })
312
+ ?.sessionId, (input) => {
312
313
  if (this.sessionScheduleService === undefined)
313
314
  throw new Error('SCHEDULE_SERVICE_UNAVAILABLE');
314
315
  return this.sessionScheduleService.createFromMcp(input);
315
- }, (sessionId) => this.runtime.getAgentSession(sessionId)?.externalSessionId ?? sessionId);
316
+ });
316
317
  notificationMcpBridge = new NotificationMcpBridge((capability) => resolveNotificationCapability(this.runtime, capability), (request) => this.mailNotificationControl.request(request), {
317
318
  supported: () => this.mailNotificationControl.supportsChannels(),
318
319
  send: (request) => this.mailNotificationControl.notification(request)
@@ -889,7 +890,6 @@ export class NodeConnector {
889
890
  migrateAttachments: (previousId, nextId) => this.runAttachmentService.migrateSession(previousId, nextId),
890
891
  migrateGoal: (previousId, nextId) => {
891
892
  this.goalCoordinator.migrateSession(previousId, nextId);
892
- this.goalMcpBridge.migrateSession(previousId, nextId);
893
893
  },
894
894
  migrateQueue: (previousId, nextId) => this.continuousRunCoordinator.migrateSession(previousId, nextId),
895
895
  migrateRunnerState: (previousId, nextId) => {
@@ -1272,7 +1272,6 @@ export class NodeConnector {
1272
1272
  this.mailNotificationControl.disconnected();
1273
1273
  this.marctlExecutionCredentials.disconnected();
1274
1274
  this.upgradeCredentials.disconnect();
1275
- this.notificationMcpBridge.revokeAll();
1276
1275
  this.terminalChannel.disconnect();
1277
1276
  if (code === 4000 && reason === 'NODE_CONNECTION_REPLACED')
1278
1277
  nodeLog('node.connection.replaced', {
@@ -1488,7 +1487,7 @@ export class NodeConnector {
1488
1487
  {}),
1489
1488
  emitNotification: (payload) => this.emitWorkbenchEvent('conversation', payload)
1490
1489
  });
1491
- this.executionLifecycle = new ExecutionLifecycle(() => this.database !== undefined, this.scheduledSessions, this.continuousRunCoordinator, this.goalCoordinator, this.modelUsage, this.goalMcpBridge, this.scheduleMcpBridge, this.notificationMcpBridge, this.runEventBridge, this.upgradeCoordinator);
1490
+ this.executionLifecycle = new ExecutionLifecycle(() => this.database !== undefined, this.scheduledSessions, this.continuousRunCoordinator, this.goalCoordinator, this.modelUsage, this.runtime, this.runEventBridge, this.upgradeCoordinator);
1492
1491
  // Register public operations only after domain workflows are connected.
1493
1492
  this.runnerService = new RunnerService(requireDatabase, () => this.capabilities, this.runners, this.runnerUsageReader);
1494
1493
  this.workbenchManifestService = new WorkbenchManifestService({
@@ -1 +1 @@
1
- {"type":"module","version":"0.13.15"}
1
+ {"type":"module","version":"0.13.16"}
@@ -0,0 +1,38 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ /** Signs process-local MCP claims; authorization and revocation belong to the Execution owner. */
3
+ export class McpCapabilityToken {
4
+ purpose;
5
+ schema;
6
+ key;
7
+ constructor(purpose, schema, key = randomBytes(32)) {
8
+ this.purpose = purpose;
9
+ this.schema = schema;
10
+ this.key = key;
11
+ }
12
+ sign(claims) {
13
+ const body = `v1.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
14
+ return `${body}.${this.signature(body).toString('base64url')}`;
15
+ }
16
+ verify(token) {
17
+ if (typeof token !== 'string' || token.length > 8192)
18
+ return undefined;
19
+ const match = /^(v1\.[A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/u.exec(token);
20
+ if (match === null)
21
+ return undefined;
22
+ const expected = this.signature(match[1]);
23
+ const received = Buffer.from(match[2], 'base64url');
24
+ if (expected.length !== received.length || !timingSafeEqual(expected, received))
25
+ return undefined;
26
+ try {
27
+ const value = JSON.parse(Buffer.from(match[1].slice(3), 'base64url').toString('utf8'));
28
+ const parsed = this.schema.safeParse(value);
29
+ return parsed.success ? parsed.data : undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ signature(body) {
36
+ return createHmac('sha256', this.key).update(`${this.purpose}\0${body}`).digest();
37
+ }
38
+ }
@@ -78,7 +78,7 @@ async function queryScreens(signal) {
78
78
  });
79
79
  return JSON.parse(stdout);
80
80
  }
81
- /** Owns the current interactive Windows GUI layout and one shared UltraVNC backend. */
81
+ /** Owns serialized Windows GUI layout discovery and one shared UltraVNC backend. */
82
82
  export class WindowsRemoteDesktopRuntime {
83
83
  options;
84
84
  #pool;
@@ -87,6 +87,7 @@ export class WindowsRemoteDesktopRuntime {
87
87
  #signature = '';
88
88
  #revision = 0;
89
89
  #disposed = false;
90
+ #refreshTail = Promise.resolve();
90
91
  constructor(options) {
91
92
  this.options = options;
92
93
  this.#pool = new TigerVncBackendPool((signal, onUnexpectedExit) => UltraVncManagedProcess.start({ binaryPath: options.binaryPath, onUnexpectedExit }, signal), () => options.fence('DESKTOP_BACKEND_CLOSED'));
@@ -96,7 +97,12 @@ export class WindowsRemoteDesktopRuntime {
96
97
  await runtime.refresh(signal);
97
98
  return runtime;
98
99
  }
99
- async refresh(signal) {
100
+ refresh(signal) {
101
+ const result = this.#refreshTail.then(() => this.#refresh(signal));
102
+ this.#refreshTail = result.catch(() => { });
103
+ return result;
104
+ }
105
+ async #refresh(signal) {
100
106
  const screens = (await queryScreens(signal)).sort((left, right) => left.name.localeCompare(right.name));
101
107
  const signature = JSON.stringify(screens.map((screen) => [
102
108
  screen.name,
@@ -711,6 +711,30 @@ export class NodeRuntimeState {
711
711
  const canonical = this.executions.get(id)?.runId ?? id;
712
712
  return this.runs.get(canonical);
713
713
  }
714
+ /** Resolves signed MCP scope against live execution ownership, never a token registry. */
715
+ resolveMcpExecution(scope) {
716
+ const execution = this.executions.get(scope.executionId);
717
+ const run = execution === undefined ? undefined : this.runs.get(execution.runId);
718
+ const session = this.getAgentSession(scope.sessionId);
719
+ if (execution === undefined ||
720
+ execution.mcpRevoked === true ||
721
+ terminalExecution(execution.status) ||
722
+ this.isExecutionInterruptRequested(execution.id) ||
723
+ run === undefined ||
724
+ (run.status !== 'STARTING' && run.status !== 'RUNNING') ||
725
+ this.currentExecutionId(run.id) !== execution.id ||
726
+ session === undefined ||
727
+ session.id !== run.sessionId ||
728
+ (scope.workspaceId !== undefined && scope.workspaceId !== run.workspaceId) ||
729
+ (scope.userId !== undefined && scope.userId !== run.initiatedByUserId))
730
+ return undefined;
731
+ return run;
732
+ }
733
+ revokeExecutionMcps(executionId) {
734
+ const execution = this.executions.get(executionId);
735
+ if (execution !== undefined)
736
+ this.executions.set(executionId, { ...execution, mcpRevoked: true });
737
+ }
714
738
  /** Only the current execution may replace an active Run's ephemeral metrics. */
715
739
  updateRunPerformance(id, performance) {
716
740
  const run = this.getRun(id);
@@ -1,23 +1,34 @@
1
1
  import { NODE_MCP } from '../mcp/naming.js';
2
- import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { McpCapabilityToken } from '../mcp/capability-token.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { z } from 'zod';
3
5
  import { existsSync } from 'node:fs';
4
6
  import { createServer } from 'node:net';
5
7
  import { tmpdir } from 'node:os';
6
8
  import { join } from 'node:path';
7
9
  import { unlink } from 'node:fs/promises';
8
10
  import { fileURLToPath } from 'node:url';
11
+ const capabilitySchema = z.object({
12
+ executionId: z.string(),
13
+ sessionId: z.string(),
14
+ goalId: z.string(),
15
+ revision: z.number().int().nonnegative()
16
+ });
17
+ /** Owns Goal IPC and revision validation, not Execution authorization or Session aliases. */
9
18
  export class GoalMcpBridge {
10
19
  goals;
11
20
  changed;
12
- capabilities = new Map();
21
+ resolveExecution;
22
+ tokens = new McpCapabilityToken(NODE_MCP.goal.name, capabilitySchema);
13
23
  sockets = new Set();
14
24
  endpoint = process.platform === 'win32'
15
25
  ? `\\\\.\\pipe\\mar-goal-${process.pid}-${randomUUID()}`
16
26
  : join(tmpdir(), `mar-goal-${process.pid}-${randomUUID()}.sock`);
17
27
  listening;
18
- constructor(goals, changed) {
28
+ constructor(goals, changed, resolveExecution) {
19
29
  this.goals = goals;
20
30
  this.changed = changed;
31
+ this.resolveExecution = resolveExecution;
21
32
  }
22
33
  listen() {
23
34
  this.listening ??= new Promise((resolve, reject) => {
@@ -30,8 +41,7 @@ export class GoalMcpBridge {
30
41
  async prepare(capability) {
31
42
  await this.listen();
32
43
  const args = goalMcpStdioArgs();
33
- const token = randomBytes(32).toString('base64url');
34
- this.capabilities.set(token, capability);
44
+ const token = this.tokens.sign(capability);
35
45
  return {
36
46
  name: NODE_MCP.goal.name,
37
47
  origin: 'BUILTIN',
@@ -53,7 +63,6 @@ export class GoalMcpBridge {
53
63
  if (this.listening === undefined)
54
64
  return;
55
65
  const server = await this.listening;
56
- this.capabilities.clear();
57
66
  const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
58
67
  for (const socket of this.sockets)
59
68
  socket.destroy();
@@ -61,18 +70,6 @@ export class GoalMcpBridge {
61
70
  if (process.platform !== 'win32')
62
71
  await unlink(this.endpoint).catch(() => undefined);
63
72
  }
64
- revoke(executionId) {
65
- for (const [token, capability] of this.capabilities)
66
- if (capability.executionId === executionId)
67
- this.capabilities.delete(token);
68
- }
69
- migrateSession(previousId, nextId) {
70
- if (previousId === nextId)
71
- return;
72
- for (const [token, capability] of this.capabilities)
73
- if (capability.sessionId === previousId)
74
- this.capabilities.set(token, { ...capability, sessionId: nextId });
75
- }
76
73
  handle(socket) {
77
74
  this.sockets.add(socket);
78
75
  socket.once('close', () => this.sockets.delete(socket));
@@ -90,9 +87,13 @@ export class GoalMcpBridge {
90
87
  async respond(socket, body) {
91
88
  try {
92
89
  const request = JSON.parse(body);
93
- const capability = typeof request.token === 'string' ? this.capabilities.get(request.token) : undefined;
94
- if (capability === undefined)
90
+ const claims = this.tokens.verify(request.token);
91
+ if (claims === undefined)
95
92
  throw new Error('GOAL_CAPABILITY_INVALID');
93
+ const sessionId = this.resolveExecution(claims);
94
+ if (sessionId === undefined)
95
+ throw new Error('GOAL_CAPABILITY_STALE');
96
+ const capability = { ...claims, sessionId };
96
97
  const current = this.current(capability);
97
98
  if (request.tool === NODE_MCP.goal.tools.get) {
98
99
  socket.end(`${JSON.stringify({ result: current })}\n`);
@@ -1,5 +1,7 @@
1
1
  import { NODE_MCP } from '../mcp/naming.js';
2
- import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { McpCapabilityToken } from '../mcp/capability-token.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { z } from 'zod';
3
5
  import { existsSync } from 'node:fs';
4
6
  import { unlink } from 'node:fs/promises';
5
7
  import { createServer } from 'node:net';
@@ -8,11 +10,19 @@ import { join } from 'node:path';
8
10
  import { fileURLToPath } from 'node:url';
9
11
  import { notificationInputSchema, notificationChannelsRequestSchema, mailNotificationRequestSchema } from '@myagentroam/protocol';
10
12
  import { sessionGroup } from '../session/session-presentation-service.js';
13
+ const capabilitySchema = z.object({
14
+ executionId: z.string(),
15
+ sessionId: z.string(),
16
+ workspaceId: z.string(),
17
+ userId: z.number().int().positive(),
18
+ source: z.enum(['MANUAL', 'SCHEDULED'])
19
+ });
20
+ /** Owns notification IPC and signed scope; live authorization belongs to Runtime and Server. */
11
21
  export class NotificationMcpBridge {
12
22
  resolve;
13
23
  send;
14
24
  channels;
15
- capabilities = new Map();
25
+ tokens = new McpCapabilityToken(NODE_MCP.notification.name, capabilitySchema);
16
26
  sockets = new Set();
17
27
  endpoint = process.platform === 'win32'
18
28
  ? `\\\\.\\pipe\\mar-notification-${process.pid}-${randomUUID()}`
@@ -26,9 +36,8 @@ export class NotificationMcpBridge {
26
36
  async prepare(capability) {
27
37
  await this.listen();
28
38
  const args = notificationMcpStdioArgs();
29
- const token = randomBytes(32).toString('base64url');
39
+ const token = this.tokens.sign(capability);
30
40
  const notificationChannels = this.channels?.supported() === true;
31
- this.capabilities.set(token, capability);
32
41
  return {
33
42
  name: NODE_MCP.notification.name,
34
43
  origin: 'BUILTIN',
@@ -49,19 +58,10 @@ export class NotificationMcpBridge {
49
58
  }
50
59
  };
51
60
  }
52
- revoke(executionId) {
53
- for (const [token, capability] of this.capabilities)
54
- if (capability.executionId === executionId)
55
- this.capabilities.delete(token);
56
- }
57
- revokeAll() {
58
- this.capabilities.clear();
59
- }
60
61
  async close() {
61
62
  if (this.listening === undefined)
62
63
  return;
63
64
  const server = await this.listening;
64
- this.revokeAll();
65
65
  const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
66
66
  for (const socket of this.sockets)
67
67
  socket.destroy();
@@ -100,7 +100,7 @@ export class NotificationMcpBridge {
100
100
  async respond(socket, body) {
101
101
  try {
102
102
  const request = JSON.parse(body);
103
- const capability = typeof request['token'] === 'string' ? this.capabilities.get(request['token']) : undefined;
103
+ const capability = this.tokens.verify(request['token']);
104
104
  if (capability === undefined)
105
105
  throw new Error('MAIL_CAPABILITY_INVALID');
106
106
  const current = this.resolve(capability);
@@ -171,15 +171,10 @@ export class NotificationMcpBridge {
171
171
  }
172
172
  }
173
173
  export function resolveNotificationCapability(runtime, capability) {
174
- const run = runtime.getRun(capability.executionId);
174
+ const run = runtime.resolveMcpExecution(capability);
175
175
  const session = runtime.getAgentSession(capability.sessionId);
176
176
  if (run === undefined ||
177
177
  session === undefined ||
178
- ['SUCCEEDED', 'FAILED', 'INTERRUPTED'].includes(run.status) ||
179
- runtime.currentExecutionId(run.id) !== capability.executionId ||
180
- run.sessionId !== session.id ||
181
- run.workspaceId !== capability.workspaceId ||
182
- run.initiatedByUserId !== capability.userId ||
183
178
  (run.source === 'SCHEDULED' ? 'SCHEDULED' : 'MANUAL') !== capability.source)
184
179
  return undefined;
185
180
  return { ...capability, sessionId: session.id, sessionGroup: sessionGroup(session) };
@@ -1,35 +1,45 @@
1
1
  import { NODE_MCP } from '../mcp/naming.js';
2
- import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { McpCapabilityToken } from '../mcp/capability-token.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { z } from 'zod';
3
5
  import { existsSync } from 'node:fs';
4
6
  import { unlink } from 'node:fs/promises';
5
7
  import { createServer } from 'node:net';
6
8
  import { tmpdir } from 'node:os';
7
9
  import { join } from 'node:path';
8
10
  import { fileURLToPath } from 'node:url';
11
+ const capabilitySchema = z.object({
12
+ executionId: z.string(),
13
+ sessionId: z.string(),
14
+ workspaceId: z.string().optional(),
15
+ taskId: z.string().optional(),
16
+ revision: z.number().int().nonnegative().optional(),
17
+ actorUserId: z.number().int().positive()
18
+ });
19
+ /** Owns task IPC, revision checks and private credential renewal after successful edits. */
9
20
  export class ScheduleMcpBridge {
10
21
  store;
11
22
  changed;
12
23
  proposed;
24
+ resolveExecution;
13
25
  create;
14
- resolveSessionId;
15
- #capabilities = new Map();
26
+ #tokens = new McpCapabilityToken(NODE_MCP.schedule.name, capabilitySchema);
16
27
  #sockets = new Set();
17
28
  #endpoint = process.platform === 'win32'
18
29
  ? `\\\\.\\pipe\\mar-schedule-${process.pid}-${randomUUID()}`
19
30
  : join(tmpdir(), `mar-schedule-${process.pid}-${randomUUID()}.sock`);
20
31
  #listening;
21
- constructor(store, changed, proposed, create, resolveSessionId) {
32
+ constructor(store, changed, proposed, resolveExecution, create) {
22
33
  this.store = store;
23
34
  this.changed = changed;
24
35
  this.proposed = proposed;
36
+ this.resolveExecution = resolveExecution;
25
37
  this.create = create;
26
- this.resolveSessionId = resolveSessionId;
27
38
  }
28
39
  async prepare(capability) {
29
40
  await this.#listen();
30
41
  const args = scheduleMcpStdioArgs();
31
- const token = randomBytes(32).toString('base64url');
32
- this.#capabilities.set(token, capability);
42
+ const token = this.#tokens.sign(capability);
33
43
  return {
34
44
  name: NODE_MCP.schedule.name,
35
45
  origin: 'BUILTIN',
@@ -48,16 +58,10 @@ export class ScheduleMcpBridge {
48
58
  }
49
59
  };
50
60
  }
51
- revoke(executionId) {
52
- for (const [token, capability] of this.#capabilities)
53
- if (capability.executionId === executionId)
54
- this.#capabilities.delete(token);
55
- }
56
61
  async close() {
57
62
  if (this.#listening === undefined)
58
63
  return;
59
64
  const server = await this.#listening;
60
- this.#capabilities.clear();
61
65
  const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
62
66
  for (const socket of this.#sockets)
63
67
  socket.destroy();
@@ -91,19 +95,24 @@ export class ScheduleMcpBridge {
91
95
  async #respond(socket, body) {
92
96
  try {
93
97
  const request = JSON.parse(body);
94
- const capability = typeof request.token === 'string' ? this.#capabilities.get(request.token) : undefined;
98
+ const capability = this.#tokens.verify(request.token);
95
99
  if (capability === undefined)
96
100
  throw new Error('SCHEDULE_CAPABILITY_INVALID');
97
- const sessionId = this.resolveSessionId?.(capability.sessionId) ?? capability.sessionId;
98
- const task = this.store().get(sessionId);
99
- if ((task === undefined && capability.taskId !== undefined) ||
100
- (task !== undefined &&
101
- (task.id !== capability.taskId || task.revision !== capability.revision)))
101
+ const sessionId = this.resolveExecution(capability);
102
+ if (sessionId === undefined)
102
103
  throw new Error('SCHEDULE_CAPABILITY_STALE');
104
+ const task = this.store().get(sessionId);
103
105
  if (request.tool === NODE_MCP.schedule.tools.get) {
106
+ // A create may commit before stdio receives the token that binds its task identity.
107
+ if (capability.taskId !== undefined && task?.id !== capability.taskId)
108
+ throw new Error('SCHEDULE_CAPABILITY_STALE');
104
109
  socket.end(`${JSON.stringify({ result: task ?? null })}\n`);
105
110
  return;
106
111
  }
112
+ if ((task === undefined && capability.taskId !== undefined) ||
113
+ (task !== undefined &&
114
+ (task.id !== capability.taskId || task.revision !== capability.revision)))
115
+ throw new Error('SCHEDULE_CAPABILITY_STALE');
107
116
  if (request.tool === NODE_MCP.schedule.tools.create) {
108
117
  if (task !== undefined)
109
118
  throw new Error('SCHEDULE_ALREADY_EXISTS');
@@ -114,19 +123,19 @@ export class ScheduleMcpBridge {
114
123
  this.create === undefined)
115
124
  throw new Error('SCHEDULE_INVALID');
116
125
  const created = await this.create({
117
- sessionId: capability.sessionId,
126
+ sessionId,
118
127
  workspaceId: capability.workspaceId,
119
128
  actorUserId: capability.actorUserId,
120
129
  objective: args.objective,
121
130
  cron: args.cron
122
131
  });
123
- this.#capabilities.set(request.token, {
132
+ const token = this.#tokens.sign({
124
133
  ...capability,
125
134
  sessionId: created.sessionId,
126
135
  taskId: created.id,
127
136
  revision: created.revision
128
137
  });
129
- socket.end(`${JSON.stringify({ result: created })}\n`);
138
+ socket.end(`${JSON.stringify({ result: created, token })}\n`);
130
139
  return;
131
140
  }
132
141
  if (request.tool !== NODE_MCP.schedule.tools.update)
@@ -140,12 +149,12 @@ export class ScheduleMcpBridge {
140
149
  socket.end(`${JSON.stringify({ result })}\n`);
141
150
  }
142
151
  else {
143
- this.#capabilities.set(request.token, {
152
+ const token = this.#tokens.sign({
144
153
  ...capability,
145
154
  revision: result.revision
146
155
  });
147
156
  this.changed(task.sessionId);
148
- socket.end(`${JSON.stringify({ result })}\n`);
157
+ socket.end(`${JSON.stringify({ result, token })}\n`);
149
158
  }
150
159
  }
151
160
  catch (error) {
@@ -4,9 +4,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { z } from 'zod';
6
6
  const endpoint = process.env['MAR_SCHEDULE_MCP_ENDPOINT'];
7
- const token = process.env['MAR_SCHEDULE_MCP_TOKEN'];
7
+ let token = process.env['MAR_SCHEDULE_MCP_TOKEN'];
8
8
  if (endpoint === undefined || token === undefined)
9
9
  throw new Error('SCHEDULE_CAPABILITY_INVALID');
10
+ // This client owns one rotating credential; consume renewals before dispatching later calls.
11
+ let previousRequest = Promise.resolve();
10
12
  const server = new McpServer({ name: NODE_MCP.schedule.name, version: NODE_MCP_VERSION });
11
13
  const scopeNote = 'Scope is limited to the MAR scheduled task managed by Node for this conversation; ' +
12
14
  'it does not cover repository, CI, system cron, or other timers.';
@@ -15,7 +17,7 @@ const objectiveDescription = 'Task body for one scheduled run in this conversati
15
17
  server.registerTool(NODE_MCP.schedule.tools.get, {
16
18
  description: 'Read the MAR scheduled task bound to this MAR-managed Workspace conversation, or null if none exists. ' +
17
19
  scopeNote
18
- }, async () => result(await request(NODE_MCP.schedule.tools.get, {})));
20
+ }, async (extra) => result(await request(NODE_MCP.schedule.tools.get, {}, extra.signal)));
19
21
  server.registerTool(NODE_MCP.schedule.tools.create, {
20
22
  description: 'Create the MAR scheduled task for this MAR-managed Workspace conversation. MAR runs it in this conversation for each admitted trigger using the connected Node time zone. ' +
21
23
  scopeNote,
@@ -27,7 +29,7 @@ server.registerTool(NODE_MCP.schedule.tools.create, {
27
29
  .max(64)
28
30
  .describe('Five-field cron expression interpreted in the connected MAR Node time zone. It defines the schedule separately from the per-run task body.')
29
31
  }
30
- }, async (args) => result(await request(NODE_MCP.schedule.tools.create, args)));
32
+ }, async (args, extra) => result(await request(NODE_MCP.schedule.tools.create, args, extra.signal)));
31
33
  server.registerTool(NODE_MCP.schedule.tools.update, {
32
34
  description: 'Update the MAR scheduled task for this MAR-managed Workspace conversation. MAR runs the saved objective in this conversation for each admitted trigger using the connected Node time zone. ' +
33
35
  scopeNote,
@@ -40,14 +42,24 @@ server.registerTool(NODE_MCP.schedule.tools.update, {
40
42
  .describe('Replacement five-field cron expression in the connected MAR Node time zone.')
41
43
  .optional()
42
44
  }
43
- }, async (args) => result(await request(NODE_MCP.schedule.tools.update, args)));
45
+ }, async (args, extra) => result(await request(NODE_MCP.schedule.tools.update, args, extra.signal)));
44
46
  await server.connect(new StdioServerTransport());
45
- function request(tool, args) {
47
+ function request(tool, args, signal) {
48
+ const operation = previousRequest.then(() => exchange(tool, args, signal));
49
+ previousRequest = operation.catch(() => undefined);
50
+ return operation;
51
+ }
52
+ function exchange(tool, args, signal) {
53
+ if (signal.aborted)
54
+ return Promise.reject(new Error('SCHEDULE_CALL_CANCELLED: The schedule call was cancelled before dispatch.'));
46
55
  return new Promise((resolve, reject) => {
47
56
  const socket = connect(endpoint);
57
+ const abort = () => socket.destroy(new Error('SCHEDULE_DELIVERY_UNCERTAIN: The schedule call was cancelled after dispatch; its result is uncertain.'));
58
+ signal.addEventListener('abort', abort, { once: true });
59
+ socket.once('close', () => signal.removeEventListener('abort', abort));
48
60
  let body = '';
49
61
  socket.setEncoding('utf8');
50
- socket.once('connect', () => socket.end(`${JSON.stringify({ token, tool, arguments: args })}\n`));
62
+ socket.once('connect', () => socket.write(`${JSON.stringify({ token, tool, arguments: args })}\n`));
51
63
  socket.on('data', (chunk) => (body += chunk));
52
64
  socket.once('error', reject);
53
65
  socket.once('end', () => {
@@ -55,11 +67,16 @@ function request(tool, args) {
55
67
  const payload = JSON.parse(body);
56
68
  if (payload.error !== undefined)
57
69
  reject(new Error(payload.error));
58
- else
70
+ else {
71
+ // Credential renewal stays inside IPC; model-visible results never contain tokens.
72
+ if (typeof payload.token === 'string')
73
+ token = payload.token;
59
74
  resolve(payload.result);
75
+ }
60
76
  }
61
- catch (error) {
62
- reject(error);
77
+ catch {
78
+ // JSON diagnostics may include a partially received private renewal credential.
79
+ reject(new Error('SCHEDULE_IPC_RESPONSE_INVALID: The schedule service returned an invalid response.'));
63
80
  }
64
81
  });
65
82
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.13.15",
3
+ "version": "0.13.16",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/myagentroam/mar.git",
@@ -49,14 +49,14 @@
49
49
  "turndown-plugin-gfm": "^1.0.2",
50
50
  "ws": "^8.21.3",
51
51
  "zod": "4.4.3",
52
- "@myagentroam/agent": "0.13.15",
53
- "@myagentroam/protocol": "0.13.15"
52
+ "@myagentroam/protocol": "0.13.16",
53
+ "@myagentroam/agent": "0.13.16"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/turndown": "^5.0.6",
57
57
  "@types/ws": "^8.18.1",
58
- "@myagentroam/marctl": "0.13.15",
59
- "@myagentroam/code-navigation": "0.11.1"
58
+ "@myagentroam/code-navigation": "0.11.1",
59
+ "@myagentroam/marctl": "0.13.16"
60
60
  },
61
61
  "exports": {
62
62
  "./config": {