@brightbeamai/chap-coordinator-mcp 0.3.4 → 0.3.5

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.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import { mkdirSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { dirname, resolve } from "path";
4
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
8
  import { Coordinator } from "@brightbeamai/chap-coordinator";
6
9
 
@@ -9,7 +12,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
12
  import {
10
13
  CallToolRequestSchema,
11
14
  ListToolsRequestSchema,
12
- ErrorCode as ErrorCode2
15
+ ErrorCode as ErrorCode3
13
16
  } from "@modelcontextprotocol/sdk/types.js";
14
17
  import { z } from "zod";
15
18
 
@@ -78,6 +81,18 @@ var SCHEMAS = {
78
81
  enum: ["shadow", "trial", "production"],
79
82
  default: "production",
80
83
  description: "Highest mode a task in this workspace may request. A task.create above the ceiling is refused with -32040. The ceiling can be changed afterwards with chap.control.set_mode_ceiling."
84
+ },
85
+ join_policy: {
86
+ type: "string",
87
+ enum: ["open", "invite"],
88
+ default: "open",
89
+ description: "Who may join. 'open' admits anyone. 'invite' admits only a participant an admin invited with chap.workspace.invite, and the workspace's first member, which becomes its admin; any other join is refused with -32011. It cannot be changed afterwards."
90
+ },
91
+ review_rule: {
92
+ type: "string",
93
+ pattern: "^(any_one_approves|all_approve|quorum:[1-9][0-9]*)$",
94
+ default: "any_one_approves",
95
+ description: "The rule of every review that task.complete opens here: any_one_approves, all_approve or quorum:<n>. An author cannot open a review of their own review-required task, so this is how a workspace asks for more than one approval. It cannot be changed afterwards."
81
96
  }
82
97
  },
83
98
  additionalProperties: false
@@ -99,6 +114,26 @@ var SCHEMAS = {
99
114
  },
100
115
  required: ["workspace", "profiles"]
101
116
  },
117
+ "chap.workspace.invite": {
118
+ type: "object",
119
+ properties: {
120
+ workspace: WORKSPACE_ID,
121
+ from: { ...PARTICIPANT_URI, description: "The admin who invites. Any other sender is refused with -32011." },
122
+ uri: { ...PARTICIPANT_URI, description: "The participant invited. Its scheme must name type, as at a join." },
123
+ type: {
124
+ type: "string",
125
+ enum: ["human", "agent", "service"],
126
+ description: "The kind of participant invited, which the scheme of uri must name. The member takes this type. group: and workspace: URIs name address scopes, which cannot join, and are refused with -32602."
127
+ },
128
+ role: { type: "string", description: "The role the member takes when it joins, whatever role the join names. Only an invitation grants 'admin' after the first member, and only to a human or a service." },
129
+ jwks: {
130
+ type: "object",
131
+ description: "Keys to pin, as {keys: [JWK, ...]}, each with a kid. The join's keys must be among them, and the member holds them. For a member that holds no key in force, the call registers these keys at once: the way back for a member whose keys were all revoked."
132
+ },
133
+ oidc_sub: { type: "string", description: "The OIDC subject whose token admits the invitee: the join must carry an oidc_token for it. Needs a token verifier on the coordinator." }
134
+ },
135
+ required: ["workspace", "from", "uri", "type"]
136
+ },
102
137
  "chap.participant.join": {
103
138
  type: "object",
104
139
  properties: {
@@ -106,10 +141,10 @@ var SCHEMAS = {
106
141
  from: PARTICIPANT_URI,
107
142
  type: {
108
143
  type: "string",
109
- enum: ["human", "agent", "service", "group", "workspace"],
110
- description: "The kind of participant. Only members of type 'human' are eligible for the review that chap.task.complete opens on a task marked review_required, and a completion with no eligible human is refused with -32011."
144
+ enum: ["human", "agent", "service"],
145
+ description: "The kind of participant, which the scheme of from must name: 'agent:' for an agent, and so on. Any other join is refused with -32602, and so is a join of a group: or workspace: URI, which names an address scope. Only members of type 'human' are eligible for the review that chap.task.complete opens on a task marked review_required, and a completion with no eligible human is refused with -32011. In a workspace that requires signatures, a participant that signs joins with a key."
111
146
  },
112
- role: { type: "string", description: "Operator-defined role, e.g. 'reviewer' or 'drafter'. One value is read by the coordinator: 'admin' permits revoking another member's key." },
147
+ role: { type: "string", description: "Operator-defined role, e.g. 'reviewer' or 'drafter'. One value is read by the coordinator: 'admin', which changes the profiles, invites, and revokes another member's key. It is granted, never claimed: only the workspace's first member, or a participant an admin's invitation names as admin, may name it at a join, and any other join naming it is refused with -32011. Only a human or a service holds it. An invitation's role replaces the one named here." },
113
148
  display_name: { type: "string", description: "Human-readable name for interfaces. Not used in authorisation." }
114
149
  },
115
150
  required: ["workspace", "from", "type"]
@@ -225,6 +260,11 @@ var SCHEMAS = {
225
260
  },
226
261
  required: ["workspace", "from", "task_id", "to", "artefact"]
227
262
  },
263
+ "chap.review.acknowledge": {
264
+ type: "object",
265
+ properties: { workspace: WORKSPACE_ID, from: PARTICIPANT_URI, task_id: TASK_ID },
266
+ required: ["workspace", "from", "task_id"]
267
+ },
228
268
  "chap.decide.approve": {
229
269
  type: "object",
230
270
  properties: {
@@ -729,19 +769,21 @@ var TOOL_DESCRIPTIONS = {
729
769
  "chap.workspace.create": "Create a workspace: the container for the participants, tasks and audit log of one piece of collaborative work. Returns the workspace id, which every later call carries.",
730
770
  "chap.workspace.describe": "Report the current state of a workspace: its members, enabled profiles, audit length, and task and override counts.",
731
771
  "chap.workspace.set_profiles": "Replace the whole set of profiles enabled on a workspace. The list is not merged: any profile absent from it is dropped, so send the complete set you want. Enabling audit-scitt/1.0 on a workspace that already has entries starts the chain from that point and leaves the earlier entries permanently outside it, which chap.audit.verify_chain then reports as not_evaluated. Prefer setting profiles at chap.workspace.create, where no entries exist yet to strand.",
772
+ "chap.workspace.invite": "Invite a participant to a workspace, as its admin. The invitation names the type and role the member takes when it joins, and may pin its keys and its OIDC subject. A workspace whose join policy is 'invite' admits nobody else. For a member that holds no key in force, the call registers the keys it names at once, so a member whose keys were all revoked can sign again. Where a token or credential verifier is configured, an invitation names no keys for a person: their keys arrive with the token they join with (-32602).",
732
773
  "chap.participant.join": "Add a participant to a workspace. The type given, human, agent, service, group or workspace, decides whether they are eligible to review work that requires it.",
733
774
  "chap.participant.leave": "Remove a participant from a workspace. Entries they have already written stay in the audit log and their past signatures still verify, so leaving is not a way to withdraw a decision. They can no longer be assigned tasks or act as a reviewer, and returning means a fresh chap.participant.join.",
734
775
  "chap.task.create": "Create a task: a unit of work assigned to one participant. Set review_required to make completion depend on a reviewer decision rather than on the assignee. Returns a task_id that the rest of the lifecycle takes. Supplying idempotency_key is what makes a retry safe: a repeat carrying a key already seen returns the original task, while a repeat without one creates a second task. Assigning to a paused member is refused.",
735
- "chap.task.update": "Move a task to a new state. Only the transitions in the specification's lifecycle table are accepted, and a task that requires review cannot be completed here.",
776
+ "chap.task.update": "Move a task to a new state. Only the transitions in the specification's lifecycle table are accepted, and a task that requires review cannot be completed here. A task whose review no decision has closed, or a successor that carries one on, is not declined (-32602).",
736
777
  "chap.task.complete": "Submit a task's output. A task that requires review does not complete: the output is held as the artefact under review, the task moves to review_requested, and a reviewer decision completes it. Any other task completes immediately.",
737
778
  "chap.audit.read": "Read entries from a workspace's audit log, optionally within a sequence range and filtered by method, sender, task or outcome. This is the query surface for everything the workspace has recorded, decisions and overrides included. An accepted call is under 'envelope'. A refused attempt the coordinator recorded, such as a decision by someone the review was not addressed to, is under 'request' with an 'outcome' giving the error code, and it did not take effect. Reads only: it records nothing, so it never appears in its own output. Entries come back as recorded, without aggregation, so grouping them, by tag for instance, is the reader's job.",
738
779
  // review/1.0
739
- "chap.review.request": "Open a review on a task and address it to one or more reviewers. They then call chap.decide.approve, chap.decide.reject, chap.decide.override or chap.abstain.declare. Repeating the request with the same artefact adds reviewers to the open review.",
780
+ "chap.review.request": "Open a review on a task and address it to one or more reviewers. They then call chap.decide.approve, chap.decide.reject, chap.decide.override or chap.abstain.declare, and may first say they have begun with chap.review.acknowledge. Repeating the request with the same artefact adds reviewers to the open review. A task marked review_required is reviewed through chap.task.complete, which opens its review under the workspace's rule; on such a task this tool only adds people to a review already open, and before it opens is refused with -32011.",
781
+ "chap.review.acknowledge": "Say that this reviewer has begun the open review on a task, before deciding it. The call is recorded, so the audit log shows when each reviewer took the review up as well as when they decided. It decides nothing: the review stays open and the acknowledgement counts towards no rule. Only someone who may decide the review may send it, and anyone else is refused with -32011. A task with no open review is refused with -32010, and a second acknowledgement from the same reviewer in the same round with -32602. Returns requested_at, the time the round opened, which names it.",
740
782
  "chap.decide.approve": "Approve the artefact under review as it stands. Only a reviewer the review was addressed to may decide, and anyone else is refused with -32011. The task completes once the review's rule is satisfied, so under all_approve or quorum:N an approval may leave the review open awaiting others. Use chap.decide.override instead to accept a corrected version.",
741
783
  "chap.decide.reject": "Reject the artefact under review. The task is declined, or returns to in_progress when request_revision is set, which is the choice between ending the work and sending it back. Only a reviewer the review was addressed to may decide. Use chap.decide.override instead to correct the artefact and accept it rather than refuse it.",
742
784
  "chap.decide.override": "Correct the artefact under review with an RFC 6902 JSON Patch and accept the result. The patch, the rationale and any tags are recorded together, so the audit log holds what was changed and why, rather than only that the work was not accepted as written.",
743
785
  "chap.abstain.declare": "Stand aside from a review this participant cannot decide, for example on a conflict of interest or for want of context. Use it in place of chap.decide.reject, which judges the work; abstaining judges nothing and leaves the artefact intact. It requires an open review addressed to the caller and answers -32010 otherwise. The task moves to abstained, which is not terminal: chap.escalate.raise or a fresh chap.review.request can still carry it forward.",
744
- "chap.escalate.raise": "Hand a task upwards, where the current holder can act but should not decide alone. The original moves to escalated and is linked to a new task for whoever takes it on. Use chap.handoff.propose to pass work sideways without that link, and chap.control.supersede to replace a task outright. The successor starts with an empty input unless one is supplied, so restate whatever the new assignee needs. A completed, cancelled or superseded task cannot be escalated. The successor takes the mode new_task.mode names, or else the original's, and requires review when the original did, or when it is a trial task under modes/1.0. A paused assignee is refused with -32063, and a mode above the workspace's current ceiling with -32040; after the ceiling is lowered, name a mode within it.",
786
+ "chap.escalate.raise": "Hand a task upwards, where the current holder can act but should not decide alone. The original moves to escalated and is linked to a new task for whoever takes it on. Use chap.handoff.propose to pass work sideways without that link, and chap.control.supersede to replace a task outright. The successor starts with an empty input unless one is supplied, so restate whatever the new assignee needs. A completed, cancelled or superseded task cannot be escalated. The successor takes the mode new_task.mode names, or else the original's, and requires review when the original did or held a review, or when it is a trial task under modes/1.0. Whoever made the original's work counts as an author of the successor's review. A paused assignee is refused with -32063, and a mode above the workspace's current ceiling with -32040; after the ceiling is lowered, name a mode within it.",
745
787
  // whisper/1.0
746
788
  "chap.whisper.ask": "Put one question to one or more participants, with a deadline and a default. If the deadline passes unanswered the default applies, so a task is never blocked waiting on a reply.",
747
789
  "chap.whisper.answer": "Answer an open whisper. Where the question carried options, the answer must name one of their ids and any other value is refused with -32022. A whisper already answered is refused with -32020 and one past its deadline with -32021, in which case the default supplied at chap.whisper.ask has already been applied.",
@@ -757,10 +799,10 @@ var TOOL_DESCRIPTIONS = {
757
799
  // control/1.0
758
800
  "chap.control.pause": "Pause work. Scoped to a task it moves that task to paused; to a participant it stops new tasks being assigned to them; to the workspace it refuses every method except describing, reading the audit log, joining, leaving and resuming.",
759
801
  "chap.control.resume": "Resume work paused at the same scope: a task returns to the state it held at the pause, a participant can be assigned tasks again, a workspace returns to active.",
760
- "chap.control.cancel": "Cancel a task. Cancelled is terminal, and a task that has already settled cannot be cancelled.",
802
+ "chap.control.cancel": "Cancel a task. Cancelled is terminal, and a task that has already settled cannot be cancelled. From the moment a review the task requires opens until a reviewer rejects the work, the authors of the work cannot cancel it, even once they take the work back, nor a successor that carries its review (-32011).",
761
803
  "chap.control.snapshot": "Capture the workspace state as an artefact before a change you may want to undo. Returns a snapshot artefact id, which is the only thing chap.control.rollback accepts as a target; the label is for a human reading the log and cannot be used to select it. Taking a snapshot changes nothing. Note that a rollback restores only the mode ceiling and member roles, so a snapshot is a narrow safety net rather than a general undo.",
762
804
  "chap.control.rollback": "Restore workspace state from a snapshot. The mode ceiling and member roles are restored; the rollback is appended to the audit log rather than rewriting it.",
763
- "chap.control.supersede": "Replace a task with a successor in one call. The original moves to superseded and stays linked to its replacement. The successor requires review when the original did, whatever its own review_required says, and a paused assignee is refused with -32063.",
805
+ "chap.control.supersede": "Replace a task with a successor in one call. The original moves to superseded and stays linked to its replacement. The successor requires review when the original did, whatever its own review_required says, and whoever made the original's work counts as an author of the successor's review. A paused assignee is refused with -32063, and from the moment a review the original requires opens until a reviewer rejects the work, the authors of the work cannot supersede it, nor a successor that carries its review (-32011).",
764
806
  "chap.control.set_mode_ceiling": "Set the highest operating mode tasks in this workspace may request. A task above the ceiling is refused.",
765
807
  // routing/1.0
766
808
  "chap.task.route": "Choose an assignee for a task from a list of candidates and reassign the task to the one selected. Use it where the choice itself belongs on the record: it writes a route_decision artefact naming the policy, the candidate chosen and the alternatives passed over, none of which is captured by setting assignee directly on chap.task.create. The default policy takes the first candidate that is a workspace member and is not paused. An operator-supplied policy may choose on any basis, and the call is refused with -32510 when its choice is not a member, or with -32063 when its choice is paused.",
@@ -811,8 +853,10 @@ var IDEMPOTENT = /* @__PURE__ */ new Set([
811
853
  // pausing an already paused target changes nothing
812
854
  "chap.control.set_mode_ceiling",
813
855
  // setting the ceiling it already has changes nothing
814
- "chap.deliberate.close"
856
+ "chap.deliberate.close",
815
857
  // returns the same outcome, recomputing nothing
858
+ "chap.review.acknowledge"
859
+ // a second acknowledgement in the round is refused and records nothing
816
860
  ]);
817
861
  var OPEN_WORLD = /* @__PURE__ */ new Set([
818
862
  "chap.audit.submit_to_scitt",
@@ -822,6 +866,7 @@ var TITLES = {
822
866
  "chap.workspace.create": "Create workspace",
823
867
  "chap.workspace.describe": "Describe workspace",
824
868
  "chap.workspace.set_profiles": "Replace workspace profiles",
869
+ "chap.workspace.invite": "Invite participant",
825
870
  "chap.participant.join": "Add participant",
826
871
  "chap.participant.leave": "Remove participant",
827
872
  "chap.task.create": "Create task",
@@ -829,6 +874,7 @@ var TITLES = {
829
874
  "chap.task.complete": "Submit task output",
830
875
  "chap.audit.read": "Read audit log",
831
876
  "chap.review.request": "Request review",
877
+ "chap.review.acknowledge": "Acknowledge review",
832
878
  "chap.decide.approve": "Approve",
833
879
  "chap.decide.reject": "Reject",
834
880
  "chap.decide.override": "Override with a correction",
@@ -869,6 +915,19 @@ var TOOL_ANNOTATIONS = Object.fromEntries(
869
915
  openWorldHint: OPEN_WORLD.has(name)
870
916
  }])
871
917
  );
918
+ var AGENT_MCP_TOOLS = /* @__PURE__ */ new Set([
919
+ "chap.task.create",
920
+ "chap.task.update",
921
+ "chap.task.complete",
922
+ "chap.whisper.ask",
923
+ "chap.decide.approve",
924
+ "chap.decide.reject",
925
+ "chap.decide.override",
926
+ "chap.review.acknowledge",
927
+ "chap.workspace.describe",
928
+ "chap.audit.read",
929
+ "chap.audit.verify_chain"
930
+ ]);
872
931
 
873
932
  // src/envelope.ts
874
933
  import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
@@ -920,6 +979,78 @@ function readMeta(params) {
920
979
  return meta;
921
980
  }
922
981
 
982
+ // src/remote.ts
983
+ var REMOTE_TIMEOUT_MS = 3e4;
984
+ var ChapEndpointError = class extends Error {
985
+ constructor(message) {
986
+ super(message);
987
+ this.name = "ChapEndpointError";
988
+ }
989
+ };
990
+ function remoteDispatcher(endpoint, timeoutMs = REMOTE_TIMEOUT_MS) {
991
+ return {
992
+ async dispatch(envelope) {
993
+ let response;
994
+ let text;
995
+ try {
996
+ response = await fetch(endpoint, {
997
+ method: "POST",
998
+ headers: { "content-type": "application/json", accept: "application/json" },
999
+ body: JSON.stringify(envelope),
1000
+ signal: AbortSignal.timeout(timeoutMs)
1001
+ });
1002
+ text = await response.text();
1003
+ } catch (err) {
1004
+ throw new ChapEndpointError(`The CHAP server at ${endpoint} could not be reached (${reason(err)}). Start it, or check --url.`);
1005
+ }
1006
+ let body;
1007
+ try {
1008
+ body = JSON.parse(text);
1009
+ } catch {
1010
+ throw new ChapEndpointError(`The CHAP server at ${endpoint} answered HTTP ${response.status} with a body that is not JSON.`);
1011
+ }
1012
+ if (isResponseEnvelope(body)) return body;
1013
+ const said = typeof body?.error === "string" ? `: ${body.error}` : "";
1014
+ throw new ChapEndpointError(`The CHAP server at ${endpoint} refused the call with HTTP ${response.status}${said}`);
1015
+ }
1016
+ };
1017
+ }
1018
+ function reason(err) {
1019
+ if (err instanceof Error && err.name === "TimeoutError") return "timed out";
1020
+ const cause = err.cause;
1021
+ if (typeof cause?.code === "string") return cause.code;
1022
+ if (typeof cause?.message === "string" && cause.message) return cause.message;
1023
+ return err instanceof Error ? err.message : String(err);
1024
+ }
1025
+ function isResponseEnvelope(body) {
1026
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return false;
1027
+ const b = body;
1028
+ return b.jsonrpc === "2.0" && ("result" in b || typeof b.error === "object" && b.error !== null);
1029
+ }
1030
+
1031
+ // src/http.ts
1032
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1033
+ import {
1034
+ ErrorCode as ErrorCode2,
1035
+ SUPPORTED_PROTOCOL_VERSIONS as SDK_PROTOCOL_VERSIONS,
1036
+ isJSONRPCNotification,
1037
+ isJSONRPCRequest
1038
+ } from "@modelcontextprotocol/sdk/types.js";
1039
+ var HEADER_MISMATCH = -32020;
1040
+ var DEFAULT_MAX_BODY_BYTES = 1048576 + 65536;
1041
+ var HANDSHAKE_VERSIONS = SDK_PROTOCOL_VERSIONS.filter(
1042
+ (v) => !MODERN_PROTOCOL_VERSIONS.includes(v)
1043
+ );
1044
+ var STATUS_FOR_CODE = /* @__PURE__ */ new Map([
1045
+ [ErrorCode2.ParseError, 400],
1046
+ [ErrorCode2.InvalidRequest, 400],
1047
+ [ErrorCode2.InvalidParams, 400],
1048
+ [HEADER_MISMATCH, 400],
1049
+ [UNSUPPORTED_PROTOCOL_VERSION, 400],
1050
+ [ErrorCode2.MethodNotFound, 404]
1051
+ ]);
1052
+ var INTERNAL = { code: ErrorCode2.InternalError, message: "Internal error" };
1053
+
923
1054
  // src/index.ts
924
1055
  var META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
925
1056
  var LIST_TTL_MS = 36e5;
@@ -930,10 +1061,32 @@ var ServerDiscoverRequestSchema = z.object({
930
1061
  // exist in zod 3, where it stopped the package at import.
931
1062
  params: z.optional(z.object({ _meta: z.optional(z.record(z.string(), z.unknown())) }).passthrough())
932
1063
  });
1064
+ var INTERNAL_ERROR = -32603;
1065
+ var FAILED_INSIDE = { chap_error: INTERNAL_ERROR, message: "The CHAP call failed inside the server; its log has the detail." };
1066
+ function refusePerson(participant) {
1067
+ if (participant?.startsWith("human:")) {
1068
+ throw new Error(`participant ${participant} names a person. This server acts for an agent, and a person decides in a desk; give the agent's own URI, such as agent:assistant.`);
1069
+ }
1070
+ }
1071
+ function pinnedSchema(schema, participant) {
1072
+ if (!schema.properties?.from) return schema;
1073
+ return {
1074
+ ...schema,
1075
+ properties: {
1076
+ ...schema.properties,
1077
+ from: {
1078
+ type: "string",
1079
+ description: `This server acts as ${participant}: it fills this in, and refuses a call that names anyone else.`
1080
+ }
1081
+ },
1082
+ required: (schema.required ?? []).filter((key) => key !== "from")
1083
+ };
1084
+ }
933
1085
  function makeChapMcpServer(coord, options = {}) {
1086
+ refusePerson(options.participant);
934
1087
  const serverInfo = {
935
1088
  name: options.name ?? "chap",
936
- version: options.version ?? "0.3.4"
1089
+ version: options.version ?? "0.3.5"
937
1090
  };
938
1091
  const server = new Server(
939
1092
  serverInfo,
@@ -954,7 +1107,7 @@ function makeChapMcpServer(coord, options = {}) {
954
1107
  // not already see. The annotation carries a readable one.
955
1108
  title: ann?.title ?? name,
956
1109
  description: TOOL_DESCRIPTIONS[name] ?? `CHAP method ${methodForTool(name)}.`,
957
- inputSchema: SCHEMAS[name],
1110
+ inputSchema: options.participant === void 0 ? SCHEMAS[name] : pinnedSchema(SCHEMAS[name], options.participant),
958
1111
  // Behavioural hints. Without these a client has only the prose to go
959
1112
  // on when deciding whether a call is safe to make or to retry.
960
1113
  ...ann ? { annotations: ann } : {}
@@ -973,7 +1126,7 @@ function makeChapMcpServer(coord, options = {}) {
973
1126
  });
974
1127
  server.setRequestHandler(ServerDiscoverRequestSchema, async (request) => {
975
1128
  if (classifyEnvelope(request.params) === "legacy") {
976
- throw new ProtocolError(ErrorCode2.MethodNotFound, "Method not found", "server/discover");
1129
+ throw new ProtocolError(ErrorCode3.MethodNotFound, "Method not found", "server/discover");
977
1130
  }
978
1131
  return {
979
1132
  resultType: "complete",
@@ -990,13 +1143,10 @@ function makeChapMcpServer(coord, options = {}) {
990
1143
  const terminal = (result) => era === "modern" ? { resultType: "complete", ...result } : result;
991
1144
  const toolName = request.params.name;
992
1145
  const rawArgs = request.params.arguments ?? {};
993
- const method = methodForTool(toolName);
994
- if (!method) {
995
- return terminal({
996
- isError: true,
997
- content: [{ type: "text", text: `Unknown CHAP tool: ${toolName}` }]
998
- });
1146
+ if (!Object.hasOwn(SCHEMAS, toolName)) {
1147
+ throw new ProtocolError(ErrorCode3.InvalidParams, `Unknown tool: ${toolName}`);
999
1148
  }
1149
+ const method = methodForTool(toolName);
1000
1150
  if (!filter(toolName)) {
1001
1151
  return terminal({
1002
1152
  isError: true,
@@ -1024,20 +1174,25 @@ function makeChapMcpServer(coord, options = {}) {
1024
1174
  };
1025
1175
  let response;
1026
1176
  try {
1027
- response = coord.dispatch(envelope);
1177
+ response = await coord.dispatch(envelope);
1028
1178
  } catch (e) {
1029
- const msg = e instanceof Error ? e.message : String(e);
1179
+ if (e instanceof ChapEndpointError) {
1180
+ return terminal({ isError: true, content: [{ type: "text", text: e.message }] });
1181
+ }
1182
+ console.error("CHAP dispatch failed:", e);
1030
1183
  return terminal({
1031
1184
  isError: true,
1032
- content: [{ type: "text", text: `CHAP dispatch threw: ${msg}` }]
1185
+ content: [{ type: "text", text: FAILED_INSIDE.message }]
1033
1186
  });
1034
1187
  }
1035
1188
  if (response.error) {
1189
+ const internal = response.error.code === INTERNAL_ERROR;
1190
+ if (internal) console.error("CHAP call failed:", response.error);
1036
1191
  return terminal({
1037
1192
  isError: true,
1038
1193
  content: [{
1039
1194
  type: "text",
1040
- text: JSON.stringify({
1195
+ text: JSON.stringify(internal ? FAILED_INSIDE : {
1041
1196
  chap_error: response.error.code,
1042
1197
  message: response.error.message,
1043
1198
  ...response.error.data !== void 0 ? { data: response.error.data } : {}
@@ -1056,7 +1211,7 @@ function makeChapMcpServer(coord, options = {}) {
1056
1211
  }
1057
1212
 
1058
1213
  // src/cli.ts
1059
- var VERSION = "0.3.4";
1214
+ var VERSION = "0.3.5";
1060
1215
  var DEFAULT_PROFILES = [
1061
1216
  "core/1.0",
1062
1217
  "review/1.0",
@@ -1068,41 +1223,167 @@ var DEFAULT_PROFILES = [
1068
1223
  "modes/1.0",
1069
1224
  "audit-scitt/1.0"
1070
1225
  ];
1071
- var profiles = process.env.CHAP_PROFILES ? process.env.CHAP_PROFILES.split(",").map((p) => p.trim()).filter(Boolean) : DEFAULT_PROFILES;
1072
- if (profiles.length === 0) {
1073
- process.stderr.write("CHAP_PROFILES was set but empty. Unset it to use the defaults.\n");
1074
- process.exit(1);
1226
+ var DEFAULT_PARTICIPANT = "agent:assistant";
1227
+ var USAGE = `Usage: chap-mcp-server [options]
1228
+
1229
+ Serves CHAP to one MCP client on stdio, making every call as one participant.
1230
+
1231
+ Options:
1232
+ --as <uri> the participant every call is made as (CHAP_PARTICIPANT,
1233
+ default ${DEFAULT_PARTICIPANT}). A human: URI is refused:
1234
+ a person decides in a desk, never through the assistant
1235
+ --tools agent|all agent, the default: the tools an agent's own work needs,
1236
+ listed below; all: every CHAP method
1237
+ --url <endpoint> forward every call to a running CHAP server's JSON-RPC
1238
+ endpoint, such as http://127.0.0.1:8792/chap, which
1239
+ keeps the workspaces
1240
+ --db <path> keep the workspaces in this SQLite file (CHAP_DB_PATH);
1241
+ a leading ~ is the home directory. Without --db or
1242
+ --url, state is in memory only
1243
+ --profiles <list> comma-separated profiles for new workspaces
1244
+ (CHAP_PROFILES, default those listed below)
1245
+ --help print this and exit
1246
+
1247
+ The agent tools:
1248
+ ${[...AGENT_MCP_TOOLS].sort().map((name) => ` ${name}`).join("\n")}
1249
+
1250
+ The default profiles:
1251
+ ${DEFAULT_PROFILES.map((profile) => ` ${profile}`).join("\n")}
1252
+ `;
1253
+ var URI_RE = /^[a-z][a-z0-9+.-]*:[\x21\x23-\x5b\x5d-\x7e]+$/;
1254
+ var UsageError = class extends Error {
1255
+ };
1256
+ function fromEnv(name) {
1257
+ const value = process.env[name]?.trim();
1258
+ return value ? value : void 0;
1259
+ }
1260
+ function expandHome(path) {
1261
+ return /^~(?=$|[\\/])/.test(path) ? homedir() + path.slice(1) : path;
1262
+ }
1263
+ function parseArgs(argv) {
1264
+ const given = {};
1265
+ let help = false;
1266
+ for (let i = 0; i < argv.length; i++) {
1267
+ const arg = argv[i];
1268
+ if (arg === "--help" || arg === "-h") {
1269
+ help = true;
1270
+ continue;
1271
+ }
1272
+ const m = /^(--as|--tools|--url|--db|--profiles)(?:=(.*))?$/.exec(arg);
1273
+ if (!m) throw new UsageError(arg.startsWith("-") ? `Unknown option ${arg}` : `Unexpected argument ${arg}`);
1274
+ const value = m[2] ?? argv[++i];
1275
+ if (value === void 0 || value === "") throw new UsageError(`${m[1]} needs a value`);
1276
+ given[m[1]] = value;
1277
+ }
1278
+ const [participant, participantFrom] = given["--as"] !== void 0 ? [given["--as"], "--as"] : fromEnv("CHAP_PARTICIPANT") !== void 0 ? [fromEnv("CHAP_PARTICIPANT"), "CHAP_PARTICIPANT"] : [DEFAULT_PARTICIPANT, "--as"];
1279
+ if (!URI_RE.test(participant)) {
1280
+ throw new UsageError(`${participantFrom} ${participant} is not a participant URI. Use the form agent:name@example.org.`);
1281
+ }
1282
+ if (participant.startsWith("human:")) {
1283
+ throw new UsageError(
1284
+ `${participantFrom} ${participant} names a person. This server acts for an assistant, and a person decides in a desk; give the assistant's own URI, such as agent:assistant.`
1285
+ );
1286
+ }
1287
+ const tools = given["--tools"] ?? "agent";
1288
+ if (tools !== "agent" && tools !== "all") throw new UsageError(`--tools takes agent or all; ${tools} is neither`);
1289
+ let url;
1290
+ if (given["--url"] !== void 0) {
1291
+ let parsed;
1292
+ try {
1293
+ parsed = new URL(given["--url"]);
1294
+ } catch {
1295
+ parsed = void 0;
1296
+ }
1297
+ if (!parsed || parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1298
+ throw new UsageError(`--url takes the http or https address of a CHAP JSON-RPC endpoint, such as http://127.0.0.1:8792/chap; ${given["--url"]} is not one`);
1299
+ }
1300
+ url = parsed.href;
1301
+ }
1302
+ const [rawDb, dbFrom] = given["--db"] !== void 0 ? [given["--db"], "--db"] : [fromEnv("CHAP_DB_PATH"), "CHAP_DB_PATH"];
1303
+ const [rawProfiles, profilesFrom] = given["--profiles"] !== void 0 ? [given["--profiles"], "--profiles"] : [fromEnv("CHAP_PROFILES"), "CHAP_PROFILES"];
1304
+ if (url && rawDb !== void 0) {
1305
+ throw new UsageError(`--url forwards to a server that keeps its own store, so ${dbFrom} does not apply. Set one or the other.`);
1306
+ }
1307
+ if (url && rawProfiles !== void 0) {
1308
+ throw new UsageError(`--url forwards to a server that sets its own profiles, so ${profilesFrom} does not apply. Set one or the other.`);
1309
+ }
1310
+ let profiles = DEFAULT_PROFILES;
1311
+ if (rawProfiles !== void 0) {
1312
+ profiles = rawProfiles.split(",").map((p) => p.trim()).filter(Boolean);
1313
+ if (profiles.length === 0) throw new UsageError(`${profilesFrom} names no profiles. Leave it unset for the defaults.`);
1314
+ }
1315
+ return {
1316
+ help,
1317
+ participant,
1318
+ tools,
1319
+ url,
1320
+ profiles,
1321
+ ...rawDb !== void 0 ? { db: resolve(expandHome(rawDb)) } : {}
1322
+ };
1075
1323
  }
1076
1324
  async function openStore(dbPath) {
1077
1325
  try {
1326
+ mkdirSync(dirname(dbPath), { recursive: true });
1078
1327
  const { SqliteStore } = await import("@brightbeamai/chap-coordinator/storage/sqlite");
1079
1328
  return new SqliteStore(dbPath);
1080
1329
  } catch (err) {
1081
1330
  const detail = err instanceof Error ? err.message : String(err);
1082
1331
  process.stderr.write(
1083
- `CHAP_DB_PATH is set to ${dbPath} but the SQLite store could not be opened.
1332
+ `The SQLite store at ${dbPath} could not be opened.
1084
1333
  ${detail}
1085
1334
  better-sqlite3 is an optional native dependency; reinstall with build tools
1086
- available, or unset CHAP_DB_PATH to run in memory without persistence.
1335
+ available, or leave --db and CHAP_DB_PATH unset to run in memory without persistence.
1087
1336
  `
1088
1337
  );
1089
1338
  process.exit(1);
1090
1339
  }
1091
1340
  }
1092
1341
  async function main() {
1093
- const dbPath = process.env.CHAP_DB_PATH?.trim();
1094
- const store = dbPath ? await openStore(dbPath) : void 0;
1095
- const coord = new Coordinator({ defaultProfiles: profiles, ...store ? { store } : {} });
1096
- await coord.start();
1097
- const server = makeChapMcpServer(coord, { name: "chap", version: VERSION });
1098
- process.stderr.write(`CHAP MCP server ${VERSION} on stdio.
1342
+ let options;
1343
+ try {
1344
+ options = parseArgs(process.argv.slice(2));
1345
+ } catch (err) {
1346
+ if (!(err instanceof UsageError)) throw err;
1347
+ process.stderr.write(`${err.message}
1348
+ Run chap-mcp-server --help for the options.
1099
1349
  `);
1100
- process.stderr.write(`Profiles: ${profiles.join(", ")}
1350
+ process.exit(2);
1351
+ }
1352
+ if (options.help) {
1353
+ process.stdout.write(USAGE);
1354
+ return;
1355
+ }
1356
+ const toolSet = options.tools === "agent" ? "the agent tools" : "every CHAP tool";
1357
+ process.stderr.write(`CHAP MCP server ${VERSION} on stdio, acting as ${options.participant} with ${toolSet}.
1101
1358
  `);
1102
- process.stderr.write(
1103
- dbPath ? `Persisting to ${dbPath}
1104
- ` : "In memory. Set CHAP_DB_PATH to keep workspaces across restarts.\n"
1105
- );
1359
+ let dispatcher;
1360
+ if (options.url) {
1361
+ dispatcher = remoteDispatcher(options.url);
1362
+ process.stderr.write(`Forwarding every call to ${options.url}, whose coordinator keeps the workspaces.
1363
+ `);
1364
+ } else {
1365
+ const store = options.db ? await openStore(options.db) : void 0;
1366
+ const coord = new Coordinator({ defaultProfiles: options.profiles, ...store ? { store } : {} });
1367
+ await coord.start();
1368
+ dispatcher = coord;
1369
+ process.stderr.write(`Profiles: ${options.profiles.join(", ")}
1370
+ `);
1371
+ process.stderr.write(
1372
+ options.db ? `Persisting to ${options.db}
1373
+ ` : "State is in memory only: the workspaces are lost when this process exits. Set --db or CHAP_DB_PATH to keep them.\n"
1374
+ );
1375
+ if (options.tools === "agent" && coord.workspaces.size === 0) {
1376
+ process.stderr.write(
1377
+ "No workspace exists yet, and the agent tools cannot create one: run once with --tools all, or use --url to reach a server that has one.\n"
1378
+ );
1379
+ }
1380
+ }
1381
+ const server = makeChapMcpServer(dispatcher, {
1382
+ name: "chap",
1383
+ version: VERSION,
1384
+ participant: options.participant,
1385
+ ...options.tools === "agent" ? { toolFilter: (name) => AGENT_MCP_TOOLS.has(name) } : {}
1386
+ });
1106
1387
  await server.connect(new StdioServerTransport());
1107
1388
  }
1108
1389
  main().catch((err) => {