@chatroomcp/chatroom 0.1.4 → 0.1.6

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/cli/index.js CHANGED
File without changes
@@ -0,0 +1,26 @@
1
+ import type { McpServer, StandardSchemaWithJSON, ToolAnnotations } from "@modelcontextprotocol/server";
2
+ import type { OperationLog } from "../../operations/operation-log.js";
3
+ type PluginToolInput<Schema extends StandardSchemaWithJSON> = StandardSchemaWithJSON.InferOutput<Schema>;
4
+ type PluginToolAction<Input> = string | ((input: Input) => string);
5
+ export interface PluginToolExecution {
6
+ readonly operationId: string;
7
+ /** Keep the operation running after the MCP handler returns. The owner must finish it later. */
8
+ deferCompletion(): void;
9
+ }
10
+ export interface PluginToolConfig<InputSchema extends StandardSchemaWithJSON, OutputSchema extends StandardSchemaWithJSON> {
11
+ title: string;
12
+ description: string;
13
+ inputSchema: InputSchema;
14
+ outputSchema: OutputSchema;
15
+ annotations: ToolAnnotations;
16
+ action: PluginToolAction<PluginToolInput<InputSchema>>;
17
+ }
18
+ /** Framework-owned MCP registration boundary that guarantees every plugin tool is audited. */
19
+ export declare class PluginMcpRegistrar {
20
+ private readonly server;
21
+ private readonly operations;
22
+ private readonly pluginId;
23
+ constructor(server: McpServer, operations: OperationLog, pluginId: string);
24
+ registerTool<InputSchema extends StandardSchemaWithJSON, OutputSchema extends StandardSchemaWithJSON>(name: string, config: PluginToolConfig<InputSchema, OutputSchema>, handler: (input: PluginToolInput<InputSchema>, execution: PluginToolExecution) => Promise<unknown> | unknown): void;
25
+ }
26
+ export {};
@@ -0,0 +1,63 @@
1
+ import { asChatRoomError } from "../../core/errors/chatroom-error.js";
2
+ import { mcpTool } from "./tool-support.js";
3
+ /** Framework-owned MCP registration boundary that guarantees every plugin tool is audited. */
4
+ export class PluginMcpRegistrar {
5
+ server;
6
+ operations;
7
+ pluginId;
8
+ constructor(server, operations, pluginId) {
9
+ this.server = server;
10
+ this.operations = operations;
11
+ this.pluginId = pluginId;
12
+ }
13
+ registerTool(name, config, handler) {
14
+ const { action, ...toolConfig } = config;
15
+ const callback = mcpTool(async (input) => {
16
+ const operation = this.operations.start({
17
+ pluginId: this.pluginId,
18
+ source: "mcp",
19
+ action: typeof action === "function" ? action(input) : action,
20
+ input,
21
+ ...operationReferences(input),
22
+ });
23
+ let deferred = false;
24
+ const execution = {
25
+ operationId: operation.operationId,
26
+ deferCompletion() {
27
+ deferred = true;
28
+ },
29
+ };
30
+ try {
31
+ const result = await handler(input, execution);
32
+ if (!deferred)
33
+ this.operations.finish(operation.operationId, "success", result);
34
+ return result;
35
+ }
36
+ catch (error) {
37
+ if (this.operations.get(operation.operationId)?.status === "running") {
38
+ const normalized = asChatRoomError(error);
39
+ this.operations.finish(operation.operationId, "error", null, {
40
+ code: normalized.code,
41
+ message: normalized.message,
42
+ details: normalized.details,
43
+ });
44
+ }
45
+ throw error;
46
+ }
47
+ });
48
+ this.server.registerTool(name, toolConfig, callback);
49
+ }
50
+ }
51
+ function operationReferences(input) {
52
+ if (!input || typeof input !== "object" || Array.isArray(input))
53
+ return {};
54
+ const record = input;
55
+ return {
56
+ ...(typeof record.workspaceId === "string"
57
+ ? { workspaceId: record.workspaceId }
58
+ : {}),
59
+ ...(typeof record.processId === "string"
60
+ ? { processId: record.processId }
61
+ : {}),
62
+ };
63
+ }
@@ -35,8 +35,8 @@ export declare const workspaceOutputSchema: z.ZodObject<{
35
35
  root: z.ZodString;
36
36
  sourceRoot: z.ZodString;
37
37
  mode: z.ZodEnum<{
38
- worktree: "worktree";
39
38
  checkout: "checkout";
39
+ worktree: "worktree";
40
40
  }>;
41
41
  createdAt: z.ZodString;
42
42
  lastUsedAt: z.ZodString;
@@ -14,6 +14,10 @@ export declare class OperationLog {
14
14
  run<T>(request: OperationStart, action: () => Promise<T>): Promise<T>;
15
15
  list(query?: OperationQuery): Operation[];
16
16
  get(operationId: string): Operation | null;
17
+ associate(operationId: string, references: {
18
+ workspaceId?: string | null;
19
+ processId?: string | null;
20
+ }): Operation;
17
21
  clearHistory(): {
18
22
  deleted: number;
19
23
  preserved: number;
@@ -86,6 +86,16 @@ export class OperationLog {
86
86
  get(operationId) {
87
87
  return this.repository.get(operationId);
88
88
  }
89
+ associate(operationId, references) {
90
+ const operation = this.require(operationId);
91
+ if (references.workspaceId !== undefined)
92
+ operation.workspaceId = references.workspaceId;
93
+ if (references.processId !== undefined)
94
+ operation.processId = references.processId;
95
+ this.repository.update(operation);
96
+ this.eventBus.emit({ type: "operation", operation });
97
+ return operation;
98
+ }
89
99
  clearHistory() {
90
100
  const result = this.repository.clearHistory();
91
101
  this.eventBus.emit({ type: "operations-cleared", ...result });
@@ -25,11 +25,6 @@ declare const restoreSchema: z.ZodObject<{
25
25
  publicPrefix: z.ZodNullable<z.ZodString>;
26
26
  recoveryKey: z.ZodString;
27
27
  }, z.core.$strict>;
28
- declare const prefixSchema: z.ZodObject<{
29
- publicPrefix: z.ZodString;
30
- mcpUrl: z.ZodString;
31
- webUrl: z.ZodString;
32
- }, z.core.$strict>;
33
28
  export declare class CloudApiClient {
34
29
  private readonly baseUrl;
35
30
  constructor(baseUrl: string);
@@ -44,7 +39,6 @@ export declare class CloudApiClient {
44
39
  }>;
45
40
  restore(identity: DeviceIdentity, recoveryKey: string): Promise<z.infer<typeof restoreSchema>>;
46
41
  replaceRecoveryKey(identity: DeviceIdentity): Promise<z.infer<typeof recoveryCredentialSchema>>;
47
- setPrefix(identity: DeviceIdentity, prefix: string): Promise<z.infer<typeof prefixSchema>>;
48
42
  lease(identity: DeviceIdentity, requestedServices: CloudServiceId[]): Promise<CloudLeaseState>;
49
43
  private devicePost;
50
44
  }
@@ -30,13 +30,6 @@ const restoreSchema = z
30
30
  recoveryKey: z.string().regex(/^crr\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/),
31
31
  })
32
32
  .strict();
33
- const prefixSchema = z
34
- .object({
35
- publicPrefix: z.string().min(1),
36
- mcpUrl: z.string().url(),
37
- webUrl: z.string().url(),
38
- })
39
- .strict();
40
33
  export class CloudApiClient {
41
34
  baseUrl;
42
35
  constructor(baseUrl) {
@@ -61,10 +54,6 @@ export class CloudApiClient {
61
54
  const payload = { devicePublicKey: identity.devicePublicKey };
62
55
  return recoveryCredentialSchema.parse(await this.devicePost("/v1/device/recovery-key", "recovery-key", identity, payload));
63
56
  }
64
- async setPrefix(identity, prefix) {
65
- const payload = { devicePublicKey: identity.devicePublicKey, prefix };
66
- return prefixSchema.parse(await this.devicePost("/v1/device/prefix", "prefix", identity, payload));
67
- }
68
57
  async lease(identity, requestedServices) {
69
58
  const payload = {
70
59
  devicePublicKey: identity.devicePublicKey,
@@ -34,7 +34,6 @@ export declare class CloudController {
34
34
  recoveryKey: string;
35
35
  }>;
36
36
  replaceRecoveryKey(): Promise<string>;
37
- setPrefix(prefix: string): Promise<CloudStatus>;
38
37
  setService(service: CloudServiceId, enabled: boolean): Promise<CloudStatus>;
39
38
  private refreshLeaseIfNeeded;
40
39
  private connectTunnel;
@@ -134,18 +134,6 @@ export class CloudController {
134
134
  const result = await this.api.replaceRecoveryKey(this.identity());
135
135
  return result.recoveryKey;
136
136
  }
137
- async setPrefix(prefix) {
138
- const result = await this.api.setPrefix(this.identity(), prefix);
139
- this.state = { ...this.state, publicPrefix: result.publicPrefix };
140
- await this.store.save(this.state);
141
- if (!this.stopped && this.state.entitlements.length > 0) {
142
- await this.refreshLeaseIfNeeded(true);
143
- this.connectTunnel();
144
- this.scheduleRenewal();
145
- }
146
- this.lastError = null;
147
- return this.status();
148
- }
149
137
  async setService(service, enabled) {
150
138
  if (!CLOUD_SERVICES.includes(service))
151
139
  throw new Error(`Unknown Cloud service: ${service}`);
@@ -59,14 +59,21 @@ export class CloudTunnelClient {
59
59
  this.closeWhenIdle = "stop";
60
60
  }
61
61
  updateLease(lease) {
62
- const previousServices = this.lease.services;
63
- const reconnect = lease.token !== this.lease.token ||
64
- lease.tunnelUrl !== this.lease.tunnelUrl;
65
- const removesService = previousServices.some((service) => !lease.services.includes(service));
62
+ const tunnelChanged = lease.tunnelUrl !== this.lease.tunnelUrl;
63
+ const removesService = this.lease.services.some((service) => !lease.services.includes(service));
66
64
  this.lease = lease;
67
65
  this.acceptingServices = new Set(lease.services);
68
- if (!reconnect || this.stopped)
66
+ if (this.stopped)
69
67
  return;
68
+ // A new signed lease on the same tunnel is applied in-band. This keeps the
69
+ // current WebUI/API request alive while service permissions change.
70
+ if (!tunnelChanged) {
71
+ if (this.socket?.readyState === WebSocket.OPEN)
72
+ this.send({ type: "update-lease", lease: lease.token });
73
+ return;
74
+ }
75
+ // Changing the tunnel endpoint still requires a reconnect. If a service is
76
+ // being removed, let current streams finish before moving the connection.
70
77
  if (!removesService || this.streams.size === 0) {
71
78
  this.socket?.close();
72
79
  return;
@@ -1,3 +1,4 @@
1
+ import { PluginMcpRegistrar } from "../mcp/server/plugin-mcp-registrar.js";
1
2
  export class PluginManager {
2
3
  context;
3
4
  plugins;
@@ -13,8 +14,11 @@ export class PluginManager {
13
14
  }
14
15
  }
15
16
  registerMcp(server) {
16
- for (const plugin of this.active)
17
- plugin.registerMcp?.(server);
17
+ for (const plugin of this.active) {
18
+ if (!plugin.registerMcp)
19
+ continue;
20
+ plugin.registerMcp(new PluginMcpRegistrar(server, this.context.operations, plugin.id));
21
+ }
18
22
  }
19
23
  async stop() {
20
24
  for (const plugin of [...this.active].reverse())
@@ -1,4 +1,3 @@
1
- import type { McpServer } from "@modelcontextprotocol/server";
2
- import type { OperationLog } from "../../operations/operation-log.js";
1
+ import type { PluginMcpRegistrar } from "../../mcp/server/plugin-mcp-registrar.js";
3
2
  import type { ProcessSupervisor } from "./process-supervisor.js";
4
- export declare function registerProcessTools(server: McpServer, processes: ProcessSupervisor, operations: OperationLog): void;
3
+ export declare function registerProcessTools(mcp: PluginMcpRegistrar, processes: ProcessSupervisor): void;
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { closedRead, destructiveLocalMutation, mcpTool, openWorldMutation, processSnapshotSchema, } from "../../mcp/server/tool-support.js";
3
- export function registerProcessTools(server, processes, operations) {
4
- server.registerTool("process_start", {
2
+ import { closedRead, destructiveLocalMutation, openWorldMutation, processSnapshotSchema, } from "../../mcp/server/tool-support.js";
3
+ export function registerProcessTools(mcp, processes) {
4
+ mcp.registerTool("process_start", {
5
5
  title: "Start process",
6
- description: "Start a supervised pipe or PTY process. Returns a ProcessId immediately.",
6
+ description: "Start a supervised executable directly and return a ProcessId immediately. Prefer invoking the target executable without a shell. For compound shell commands, use bash -c rather than bash -lc unless login-shell semantics are explicitly required, because a login shell may replace the inherited PATH (for example, removing NVM-managed Node.js commands).",
7
7
  inputSchema: z.object({
8
8
  command: z.string().min(1),
9
9
  args: z.array(z.string()).default([]),
@@ -14,43 +14,37 @@ export function registerProcessTools(server, processes, operations) {
14
14
  }),
15
15
  outputSchema: processSnapshotSchema,
16
16
  annotations: openWorldMutation,
17
- }, mcpTool((input) => processes.start({
18
- cwd: input.cwd,
19
- command: input.command,
20
- args: input.args,
21
- ...(input.env ? { env: input.env } : {}),
22
- pty: input.pty,
23
- ...(input.timeoutMs === undefined
24
- ? {}
25
- : { timeoutMs: input.timeoutMs }),
26
- }, { source: "mcp" })));
27
- server.registerTool("process_read", {
17
+ action: "start",
18
+ }, (input, execution) => {
19
+ execution.deferCompletion();
20
+ return processes.start({
21
+ cwd: input.cwd,
22
+ command: input.command,
23
+ args: input.args,
24
+ ...(input.env ? { env: input.env } : {}),
25
+ pty: input.pty,
26
+ ...(input.timeoutMs === undefined
27
+ ? {}
28
+ : { timeoutMs: input.timeoutMs }),
29
+ }, { source: "mcp", operationId: execution.operationId });
30
+ });
31
+ mcp.registerTool("process_read", {
28
32
  title: "Read process",
29
33
  description: "Read bounded stdout/stderr and current process state.",
30
34
  inputSchema: z.object({ processId: z.string() }),
31
35
  outputSchema: processSnapshotSchema,
32
36
  annotations: closedRead,
33
- }, mcpTool((input) => operations.run({
34
- pluginId: "process",
35
- source: "mcp",
36
37
  action: "read",
37
- processId: input.processId,
38
- input,
39
- }, () => Promise.resolve(processes.read(input.processId)))));
40
- server.registerTool("process_write", {
38
+ }, (input) => processes.read(input.processId));
39
+ mcp.registerTool("process_write", {
41
40
  title: "Write process stdin",
42
41
  description: "Write to stdin of a running process.",
43
42
  inputSchema: z.object({ processId: z.string(), data: z.string() }),
44
43
  outputSchema: processSnapshotSchema,
45
44
  annotations: openWorldMutation,
46
- }, mcpTool((input) => operations.run({
47
- pluginId: "process",
48
- source: "mcp",
49
45
  action: "write",
50
- processId: input.processId,
51
- input,
52
- }, () => Promise.resolve(processes.write(input.processId, input.data)))));
53
- server.registerTool("process_kill", {
46
+ }, (input) => processes.write(input.processId, input.data));
47
+ mcp.registerTool("process_kill", {
54
48
  title: "Stop process",
55
49
  description: "Terminate or force-kill a supervised process.",
56
50
  inputSchema: z.object({
@@ -59,11 +53,6 @@ export function registerProcessTools(server, processes, operations) {
59
53
  }),
60
54
  outputSchema: processSnapshotSchema,
61
55
  annotations: destructiveLocalMutation,
62
- }, mcpTool((input) => operations.run({
63
- pluginId: "process",
64
- source: "mcp",
65
- action: input.force ? "kill" : "terminate",
66
- processId: input.processId,
67
- input,
68
- }, () => Promise.resolve(processes.kill(input.processId, input.force)))));
56
+ action: (input) => (input.force ? "kill" : "terminate"),
57
+ }, (input) => processes.kill(input.processId, input.force));
69
58
  }
@@ -6,23 +6,20 @@ import { ProcessSupervisor } from "./process-supervisor.js";
6
6
  export const ProcessService = createServiceToken("process");
7
7
  export function createProcessPlugin() {
8
8
  let service = null;
9
- let operations = null;
10
9
  return {
11
10
  id: "process",
12
11
  activate(context) {
13
12
  service = new ProcessSupervisor({ pipe: new PipeProcessBackend(), pty: new PtyProcessBackend() }, context.operations, context.events, context.config.process.maxOutputBytes, context.config.process.defaultTimeoutMs, context.config.process.maxCompletedProcesses);
14
- operations = context.operations;
15
13
  context.services.provide(ProcessService, service);
16
14
  },
17
- registerMcp(server) {
18
- if (!service || !operations)
15
+ registerMcp(mcp) {
16
+ if (!service)
19
17
  throw new Error("Process plugin is not active");
20
- registerProcessTools(server, service, operations);
18
+ registerProcessTools(mcp, service);
21
19
  },
22
20
  async deactivate() {
23
21
  await service?.shutdown();
24
22
  service = null;
25
- operations = null;
26
23
  },
27
24
  };
28
25
  }
@@ -6,6 +6,7 @@ import type { ProcessBackend } from "./backend.js";
6
6
  interface ProcessOperationContext {
7
7
  source: OperationSource;
8
8
  action?: string;
9
+ operationId?: string;
9
10
  }
10
11
  export declare class ProcessSupervisor {
11
12
  private readonly backends;
@@ -28,20 +28,24 @@ export class ProcessSupervisor {
28
28
  throw new ChatRoomError("CONFLICT", "Process supervisor is shutting down");
29
29
  const processId = `proc_${randomUUID()}`;
30
30
  const args = request.args ?? [];
31
- const operation = this.operations.start({
32
- pluginId: "process",
33
- source: operationContext.source,
34
- action: operationContext.action ?? "start",
35
- processId,
36
- input: {
37
- command: request.command,
38
- args,
39
- cwd: request.cwd,
40
- pty: request.pty ?? false,
41
- timeoutMs: request.timeoutMs ?? this.defaultTimeoutMs,
42
- env: request.env ?? {},
43
- },
44
- });
31
+ const adoptedOperation = operationContext.operationId !== undefined;
32
+ const operationId = operationContext.operationId ??
33
+ this.operations.start({
34
+ pluginId: "process",
35
+ source: operationContext.source,
36
+ action: operationContext.action ?? "start",
37
+ processId,
38
+ input: {
39
+ command: request.command,
40
+ args,
41
+ cwd: request.cwd,
42
+ pty: request.pty ?? false,
43
+ timeoutMs: request.timeoutMs ?? this.defaultTimeoutMs,
44
+ env: request.env ?? {},
45
+ },
46
+ }).operationId;
47
+ if (adoptedOperation)
48
+ this.operations.associate(operationId, { processId });
45
49
  let backend;
46
50
  try {
47
51
  // Child environments inherit only the runtime allowlist, then apply explicit request overrides.
@@ -51,7 +55,8 @@ export class ProcessSupervisor {
51
55
  });
52
56
  }
53
57
  catch (error) {
54
- this.operations.finish(operation.operationId, "error", null, error);
58
+ if (!adoptedOperation)
59
+ this.operations.finish(operationId, "error", null, error);
55
60
  throw new ChatRoomError("PROCESS_FAILED", `Failed to start process: ${request.command}`, undefined, { cause: error });
56
61
  }
57
62
  let resolveSettled;
@@ -71,7 +76,7 @@ export class ProcessSupervisor {
71
76
  timeout: null,
72
77
  forceTimeout: null,
73
78
  timedOut: false,
74
- operationId: operation.operationId,
79
+ operationId,
75
80
  settled,
76
81
  resolveSettled,
77
82
  };
@@ -1,6 +1,6 @@
1
- import type { McpServer } from "@modelcontextprotocol/server";
2
1
  import type { ChatRoomConfig } from "../config/types.js";
3
2
  import type { AppDatabase } from "../infrastructure/database/app-database.js";
3
+ import type { PluginMcpRegistrar } from "../mcp/server/plugin-mcp-registrar.js";
4
4
  import type { OperationLog } from "../operations/operation-log.js";
5
5
  import type { RuntimeEventBus } from "../app/event-bus.js";
6
6
  import type { ExternalAccessRegistry } from "../app/external-access-registry.js";
@@ -27,6 +27,6 @@ export interface PluginContext {
27
27
  export interface InternalPlugin {
28
28
  id: string;
29
29
  activate(context: PluginContext): Promise<void> | void;
30
- registerMcp?(server: McpServer): void;
30
+ registerMcp?(mcp: PluginMcpRegistrar): void;
31
31
  deactivate?(): Promise<void> | void;
32
32
  }
@@ -15,18 +15,6 @@ export function createCloudApiRouter(controller, operations) {
15
15
  res.json(await operations.run({ pluginId: "cloud", source: "gui", action: "restore", input: body }, () => controller.restore(body.recoveryKey)));
16
16
  }));
17
17
  router.post("/cloud/recovery-key", asyncRoute(async (_req, res) => res.json(await operations.run({ pluginId: "cloud", source: "gui", action: "recovery-key.replace" }, async () => ({ recoveryKey: await controller.replaceRecoveryKey() })))));
18
- router.post("/cloud/prefix", asyncRoute(async (req, res) => {
19
- const body = z
20
- .object({ prefix: z.string().min(1).max(64) })
21
- .strict()
22
- .parse(req.body);
23
- res.json(await operations.run({
24
- pluginId: "cloud",
25
- source: "gui",
26
- action: "prefix.set",
27
- input: body,
28
- }, () => controller.setPrefix(body.prefix)));
29
- }));
30
18
  router.post("/cloud/services/:service", asyncRoute(async (req, res) => {
31
19
  const service = CLOUD_SERVICE_SCHEMA.parse(req.params.service);
32
20
  const body = z.object({ enabled: z.boolean() }).strict().parse(req.body);
@@ -1,4 +1,3 @@
1
- import type { McpServer } from "@modelcontextprotocol/server";
2
- import type { OperationLog } from "../../operations/operation-log.js";
1
+ import type { PluginMcpRegistrar } from "../../mcp/server/plugin-mcp-registrar.js";
3
2
  import type { WorkspaceService } from "./workspace-service.js";
4
- export declare function registerWorkspaceTools(server: McpServer, workspaces: WorkspaceService, operations: OperationLog): void;
3
+ export declare function registerWorkspaceTools(mcp: PluginMcpRegistrar, workspaces: WorkspaceService): void;