mcp-coordinator 0.6.1 → 0.7.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.
Files changed (66) hide show
  1. package/README.md +24 -0
  2. package/dist/src/agent-activity.d.ts +13 -9
  3. package/dist/src/agent-activity.js +45 -24
  4. package/dist/src/agent-registry.d.ts +7 -7
  5. package/dist/src/agent-registry.js +19 -18
  6. package/dist/src/announce-workflow.d.ts +1 -0
  7. package/dist/src/announce-workflow.js +13 -12
  8. package/dist/src/auth/providers/registry.d.ts +4 -0
  9. package/dist/src/auth/providers/registry.js +7 -0
  10. package/dist/src/auth/providers/types.d.ts +11 -0
  11. package/dist/src/auth/providers/types.js +1 -0
  12. package/dist/src/auth.d.ts +24 -5
  13. package/dist/src/auth.js +172 -23
  14. package/dist/src/conflict-detector.d.ts +1 -0
  15. package/dist/src/conflict-detector.js +4 -4
  16. package/dist/src/consultation.d.ts +28 -14
  17. package/dist/src/consultation.js +101 -68
  18. package/dist/src/context-provider.d.ts +2 -2
  19. package/dist/src/context-provider.js +3 -4
  20. package/dist/src/database.js +203 -4
  21. package/dist/src/dependency-map.d.ts +25 -4
  22. package/dist/src/dependency-map.js +49 -11
  23. package/dist/src/file-tracker.d.ts +5 -4
  24. package/dist/src/file-tracker.js +16 -14
  25. package/dist/src/git-cochange-builder.d.ts +11 -2
  26. package/dist/src/git-cochange-builder.js +15 -7
  27. package/dist/src/http/handle-health.d.ts +9 -5
  28. package/dist/src/http/handle-health.js +22 -8
  29. package/dist/src/http/handle-rest.d.ts +3 -0
  30. package/dist/src/http/handle-rest.js +56 -55
  31. package/dist/src/http/utils.d.ts +4 -0
  32. package/dist/src/http/utils.js +7 -1
  33. package/dist/src/impact-scorer.d.ts +3 -0
  34. package/dist/src/impact-scorer.js +65 -51
  35. package/dist/src/introspection.d.ts +13 -7
  36. package/dist/src/introspection.js +34 -11
  37. package/dist/src/metrics.js +2 -1
  38. package/dist/src/mqtt-bridge.d.ts +3 -2
  39. package/dist/src/mqtt-bridge.js +33 -23
  40. package/dist/src/mqtt-broker.d.ts +16 -7
  41. package/dist/src/mqtt-broker.js +57 -15
  42. package/dist/src/security/audit.d.ts +11 -0
  43. package/dist/src/security/audit.js +7 -0
  44. package/dist/src/security/encryption.d.ts +17 -0
  45. package/dist/src/security/encryption.js +5 -0
  46. package/dist/src/serve-http.js +136 -57
  47. package/dist/src/server-setup.d.ts +12 -2
  48. package/dist/src/server-setup.js +33 -15
  49. package/dist/src/sse-emitter.d.ts +7 -4
  50. package/dist/src/sse-emitter.js +27 -21
  51. package/dist/src/tools/agents-tools.d.ts +2 -1
  52. package/dist/src/tools/agents-tools.js +36 -12
  53. package/dist/src/tools/consultation-tools.d.ts +2 -1
  54. package/dist/src/tools/consultation-tools.js +102 -36
  55. package/dist/src/tools/dependencies-tools.d.ts +2 -1
  56. package/dist/src/tools/dependencies-tools.js +25 -7
  57. package/dist/src/tools/files-tools.d.ts +2 -1
  58. package/dist/src/tools/files-tools.js +25 -7
  59. package/dist/src/tools/mqtt-tools.d.ts +7 -1
  60. package/dist/src/tools/mqtt-tools.js +27 -4
  61. package/dist/src/tools/status-tools.d.ts +7 -1
  62. package/dist/src/tools/status-tools.js +26 -9
  63. package/dist/src/types.d.ts +2 -0
  64. package/dist/src/working-files-tracker.d.ts +21 -11
  65. package/dist/src/working-files-tracker.js +32 -21
  66. package/package.json +1 -1
package/dist/src/auth.js CHANGED
@@ -3,56 +3,143 @@ import { randomUUID } from "crypto";
3
3
  import { getDb } from "./database.js";
4
4
  import { silentLogger } from "./logger.js";
5
5
  let signingKey;
6
+ let prevKey = null;
6
7
  let defaultExpiry = "24h";
7
8
  let log = silentLogger;
8
9
  export function setAuthLogger(logger) {
9
10
  log = logger;
10
11
  }
11
- export function initAuth(secret, expiry) {
12
+ export function initAuth(secret, expiry, options = {}) {
12
13
  signingKey = new TextEncoder().encode(secret);
14
+ prevKey = options.prevSecret ? new TextEncoder().encode(options.prevSecret) : null;
13
15
  if (expiry)
14
16
  defaultExpiry = expiry;
15
17
  }
16
- export async function createToken(agentId, role, expiry) {
17
- return new SignJWT({ role })
18
+ export async function createToken(agentId, role, expiry, options = {}) {
19
+ const jti = randomUUID();
20
+ return new SignJWT({
21
+ role,
22
+ user_id: options.user_id ?? agentId,
23
+ org: options.org ?? "default",
24
+ })
18
25
  .setProtectedHeader({ alg: "HS256" })
19
26
  .setSubject(agentId)
20
- .setJti(randomUUID())
27
+ .setJti(jti)
21
28
  .setIssuedAt()
22
29
  .setExpirationTime(expiry || defaultExpiry)
23
30
  .sign(signingKey);
24
31
  }
25
32
  export async function verifyToken(token) {
26
- const { payload } = await jwtVerify(token, signingKey);
33
+ let payload;
34
+ try {
35
+ ({ payload } = await jwtVerify(token, signingKey, { algorithms: ["HS256"] }));
36
+ }
37
+ catch (err) {
38
+ if (prevKey && err instanceof errors.JWSSignatureVerificationFailed) {
39
+ ({ payload } = await jwtVerify(token, prevKey, { algorithms: ["HS256"] }));
40
+ }
41
+ else {
42
+ throw err;
43
+ }
44
+ }
27
45
  if (!payload.sub)
28
46
  throw new Error("Missing sub claim in token");
29
47
  const role = payload.role;
30
- if (role !== "agent" && role !== "admin")
48
+ if (role !== "agent" && role !== "admin" && role !== "member") {
31
49
  throw new Error("Invalid role in token");
32
- return { sub: payload.sub, role };
50
+ }
51
+ return {
52
+ sub: payload.sub,
53
+ role,
54
+ user_id: typeof payload.user_id === "string" ? payload.user_id : "legacy",
55
+ org: typeof payload.org === "string" ? payload.org : "default",
56
+ jti: typeof payload.jti === "string" ? payload.jti : randomUUID(),
57
+ };
58
+ }
59
+ export async function verifyTokenStrict(token) {
60
+ let payload;
61
+ try {
62
+ ({ payload } = await jwtVerify(token, signingKey, { algorithms: ["HS256"] }));
63
+ }
64
+ catch (err) {
65
+ if (prevKey && err instanceof errors.JWSSignatureVerificationFailed) {
66
+ ({ payload } = await jwtVerify(token, prevKey, { algorithms: ["HS256"] }));
67
+ }
68
+ else {
69
+ throw err;
70
+ }
71
+ }
72
+ if (!payload.sub)
73
+ throw new Error("Missing sub claim in token");
74
+ // Tolerate missing/unknown role on v0.6 tokens. Default to 'member' (LEAST PRIVILEGE).
75
+ const rawRole = payload.role;
76
+ const role = rawRole === "agent" || rawRole === "admin" || rawRole === "member"
77
+ ? rawRole
78
+ : "member";
79
+ // v0.7 detection: BOTH user_id AND org must be present strings.
80
+ const hasV07 = typeof payload.user_id === "string" && typeof payload.org === "string";
81
+ return {
82
+ claims: {
83
+ sub: payload.sub, role,
84
+ user_id: typeof payload.user_id === "string" ? payload.user_id : "legacy",
85
+ org: typeof payload.org === "string" ? payload.org : "default",
86
+ jti: typeof payload.jti === "string" ? payload.jti : randomUUID(),
87
+ },
88
+ wasLegacy: !hasV07,
89
+ };
33
90
  }
34
- export async function refreshToken(token, gracePeriod = "1h") {
91
+ export async function refreshToken(token, options, gracePeriod) {
92
+ const authEnabled = options.authEnabled;
93
+ const grace = gracePeriod ?? "1h";
35
94
  let claims;
36
95
  try {
37
- claims = await verifyToken(token);
96
+ const { claims: c, wasLegacy } = await verifyTokenStrict(token);
97
+ if (wasLegacy && authEnabled) {
98
+ throw new Error("v0.6 token rejected: upgrade required (AUTH_ENABLED=true)");
99
+ }
100
+ claims = c;
38
101
  }
39
102
  catch (err) {
40
103
  if (err instanceof errors.JWTExpired) {
41
- const { payload } = await jwtVerify(token, signingKey, {
42
- clockTolerance: gracePeriod,
43
- });
104
+ const verifyWith = async (key) => jwtVerify(token, key, { clockTolerance: grace, algorithms: ["HS256"] });
105
+ let payload;
106
+ try {
107
+ ({ payload } = await verifyWith(signingKey));
108
+ }
109
+ catch (err2) {
110
+ if (prevKey && err2 instanceof errors.JWSSignatureVerificationFailed) {
111
+ ({ payload } = await verifyWith(prevKey));
112
+ }
113
+ else {
114
+ throw err2;
115
+ }
116
+ }
44
117
  if (!payload.sub)
45
118
  throw new Error("Missing sub claim in token");
46
- const role = payload.role;
47
- if (role !== "agent" && role !== "admin")
48
- throw new Error("Invalid role in token");
49
- claims = { sub: payload.sub, role };
119
+ // Tolerate missing/unknown role on v0.6 tokens. Default to 'member' (LEAST PRIVILEGE).
120
+ const rawRole = payload.role;
121
+ const role = rawRole === "agent" || rawRole === "admin" || rawRole === "member"
122
+ ? rawRole
123
+ : "member";
124
+ const hasV07 = typeof payload.user_id === "string" && typeof payload.org === "string";
125
+ if (!hasV07 && authEnabled) {
126
+ throw new Error("v0.6 token rejected: upgrade required (AUTH_ENABLED=true)");
127
+ }
128
+ claims = {
129
+ sub: payload.sub, role,
130
+ user_id: typeof payload.user_id === "string" ? payload.user_id : "legacy",
131
+ org: typeof payload.org === "string" ? payload.org : "default",
132
+ jti: typeof payload.jti === "string" ? payload.jti : randomUUID(),
133
+ };
50
134
  }
51
135
  else {
52
136
  throw err;
53
137
  }
54
138
  }
55
- return createToken(claims.sub, claims.role);
139
+ return createToken(claims.sub, claims.role, undefined, {
140
+ user_id: claims.user_id,
141
+ org: claims.org,
142
+ });
56
143
  }
57
144
  export function isRevoked(agentId) {
58
145
  const db = getDb();
@@ -61,23 +148,85 @@ export function isRevoked(agentId) {
61
148
  }
62
149
  export function revokeAgent(agentId, revokedBy) {
63
150
  const db = getDb();
151
+ // INTENTIONALLY cross-org: revoked_agents is a global blocklist by design.
152
+ // A revocation issued by an admin must be effective across every org where
153
+ // that agent_id appears — there is no per-org revocation in Phase 1.
64
154
  db.prepare("INSERT OR IGNORE INTO revoked_agents (agent_id, revoked_by) VALUES (?, ?)").run(agentId, revokedBy);
65
155
  }
66
156
  const ADMIN_ONLY_ROUTES = ["/api/auth/revoke", "/api/reset"];
67
- export async function authenticateRequest(req) {
157
+ export async function authenticateRequest(req, options = { authEnabled: true }) {
158
+ const { authEnabled } = options;
68
159
  const authHeader = req.headers.authorization;
69
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
70
- return { ok: false, status: 401, error: "Missing or invalid Authorization header" };
160
+ // EventSource-compatible token transport: allow ?token=<JWT> on GET requests.
161
+ // POST/PUT/PATCH are excluded (smuggling defense: POST endpoints use the body
162
+ // as a credential channel, so hoisting a query param to auth would let an
163
+ // attacker lure a victim's browser into a CSRF-authenticated POST).
164
+ // Authorization header always takes precedence when both are present.
165
+ let effectiveAuthHeader = authHeader;
166
+ if (!effectiveAuthHeader && req.method === "GET") {
167
+ try {
168
+ // req.url may be relative (e.g. "/api/events?token=…") — prepend a dummy
169
+ // base so URL can parse it. Attacker-controlled, so we wrap in try/catch.
170
+ const parsed = new URL(req.url ?? "", "http://localhost");
171
+ const qToken = parsed.searchParams.get("token");
172
+ if (qToken) {
173
+ effectiveAuthHeader = `Bearer ${qToken}`;
174
+ }
175
+ }
176
+ catch {
177
+ // Malformed URL — no fallback, fall through to scenario (b) 401.
178
+ }
71
179
  }
72
- const token = authHeader.slice(7);
180
+ // Scenario (a)/(b): No Authorization header (and no ?token= fallback)
181
+ if (!effectiveAuthHeader || !effectiveAuthHeader.startsWith("Bearer ")) {
182
+ if (!authEnabled) {
183
+ // Scenario (a): AUTH_ENABLED=false → inject synthetic legacy claims
184
+ return {
185
+ ok: true,
186
+ claims: {
187
+ sub: "legacy",
188
+ user_id: "legacy",
189
+ org: "default",
190
+ role: "admin",
191
+ jti: randomUUID(),
192
+ },
193
+ };
194
+ }
195
+ // Scenario (b): AUTH_ENABLED=true → 401 with WWW-Authenticate
196
+ return {
197
+ ok: false,
198
+ status: 401,
199
+ error: "Missing or invalid Authorization header",
200
+ wwwAuthenticate: 'Bearer realm="mcp-coordinator", error="invalid_token"',
201
+ };
202
+ }
203
+ // Has a Bearer token — verify it
204
+ const token = effectiveAuthHeader.slice(7);
73
205
  let claims;
206
+ let wasLegacy;
74
207
  try {
75
- claims = await verifyToken(token);
208
+ ({ claims, wasLegacy } = await verifyTokenStrict(token));
76
209
  }
77
210
  catch (err) {
78
211
  log.error({ err }, "JWT verification error");
79
- return { ok: false, status: 401, error: "Invalid or expired token" };
212
+ const isExpired = err instanceof errors.JWTExpired;
213
+ return {
214
+ ok: false,
215
+ status: 401,
216
+ error: isExpired ? "Token expired" : "Invalid or expired token",
217
+ wwwAuthenticate: `Bearer realm="mcp-coordinator", error="${isExpired ? "expired_token" : "invalid_token"}"`,
218
+ };
219
+ }
220
+ // Scenario (c): v0.6 token (wasLegacy=true) under AUTH_ENABLED=true → reject
221
+ if (wasLegacy && authEnabled) {
222
+ return {
223
+ ok: false,
224
+ status: 401,
225
+ error: "v0.6 token rejected: upgrade required (AUTH_ENABLED=true)",
226
+ wwwAuthenticate: 'Bearer realm="mcp-coordinator", error="invalid_token"',
227
+ };
80
228
  }
229
+ // Scenario (c) AUTH_ENABLED=false or Scenario (d): proceed with claims
81
230
  if (isRevoked(claims.sub)) {
82
231
  return { ok: false, status: 403, error: "Agent has been revoked" };
83
232
  }
@@ -10,6 +10,7 @@ export declare class ConflictDetector {
10
10
  private log;
11
11
  constructor(consultation: Consultation, depMap: DependencyMapper, fileTracker: FileTracker, logger?: Logger);
12
12
  detect(params: {
13
+ org_id: string;
13
14
  agent_id: string;
14
15
  target_modules: string[];
15
16
  target_files: string[];
@@ -13,7 +13,7 @@ export class ConflictDetector {
13
13
  detect(params) {
14
14
  const conflicts = [];
15
15
  // Include open, resolving, and recently resolved (auto-quorum) threads — exclude only cancelled
16
- const allThreads = this.consultation.listThreads({});
16
+ const allThreads = this.consultation.listThreads(params.org_id, {});
17
17
  const activeThreads = allThreads.filter((t) => t.status !== "cancelled");
18
18
  for (const thread of activeThreads) {
19
19
  if (thread.initiator_id === params.agent_id)
@@ -46,7 +46,7 @@ export class ConflictDetector {
46
46
  }
47
47
  // 3. Dependency chain
48
48
  for (const targetModule of params.target_modules) {
49
- const info = this.depMap.getModuleInfo(targetModule);
49
+ const info = this.depMap.getModuleInfo(params.org_id, targetModule);
50
50
  if (!info)
51
51
  continue;
52
52
  for (const dep of info.depends_on) {
@@ -62,7 +62,7 @@ export class ConflictDetector {
62
62
  }
63
63
  }
64
64
  // Reverse: someone depends on what we're modifying
65
- const radius = this.depMap.getBlastRadius(targetModule);
65
+ const radius = this.depMap.getBlastRadius(params.org_id, targetModule);
66
66
  this.log.debug({
67
67
  module_id: targetModule,
68
68
  direct_dependents: radius.direct_dependents,
@@ -84,7 +84,7 @@ export class ConflictDetector {
84
84
  }
85
85
  // 4. Hot file overlap (from actual file activity, not just declared files)
86
86
  for (const targetFile of params.target_files) {
87
- const activity = this.fileTracker.checkFileConflict(targetFile, params.agent_id, 60);
87
+ const activity = this.fileTracker.checkFileConflict(params.org_id, targetFile, params.agent_id, 60);
88
88
  if (activity.conflict) {
89
89
  for (const otherAgent of activity.agents) {
90
90
  // Avoid duplicating with file_overlap already detected
@@ -2,6 +2,7 @@ import { type Logger } from "./logger.js";
2
2
  import type { Thread, ThreadMessage, ActionSummary, MessageType, ResolutionType } from "./types.js";
3
3
  export interface ResolutionEvent {
4
4
  thread_id: string;
5
+ org_id: string;
5
6
  resolution_type: ResolutionType;
6
7
  resolution_summary: string | null;
7
8
  created_at: string;
@@ -30,7 +31,7 @@ export declare class Consultation {
30
31
  startTimeoutSweeper(intervalMs?: number): void;
31
32
  stopTimeoutSweeper(): void;
32
33
  emitResolution(threadId: string, type: ResolutionType, approvedBy?: string, approvedByName?: string): void;
33
- announceWork(params: {
34
+ announceWork(orgId: string, params: {
34
35
  agent_id: string;
35
36
  subject: string;
36
37
  plan?: string;
@@ -41,7 +42,7 @@ export declare class Consultation {
41
42
  keep_open?: boolean;
42
43
  assigned_to?: string | null;
43
44
  }): Thread;
44
- postToThread(params: {
45
+ postToThread(orgId: string, params: {
45
46
  thread_id: string;
46
47
  agent_id: string;
47
48
  agent_name?: string;
@@ -50,19 +51,27 @@ export declare class Consultation {
50
51
  context_snapshot?: string;
51
52
  in_reply_to?: string;
52
53
  }): ThreadMessage;
53
- proposeResolution(threadId: string, agentId: string, summary: string): void;
54
- approveResolution(threadId: string, agentId: string, agentName?: string): void;
55
- contestResolution(threadId: string, agentId: string, reason: string): void;
56
- cancelThread(threadId: string, agentId: string, reason?: string): void;
57
- closeThread(threadId: string, agentId: string, summary: string): void;
54
+ proposeResolution(orgId: string, threadId: string, agentId: string, summary: string): void;
55
+ approveResolution(orgId: string, threadId: string, agentId: string, agentName?: string): void;
56
+ contestResolution(orgId: string, threadId: string, agentId: string, reason: string): void;
57
+ cancelThread(orgId: string, threadId: string, agentId: string, reason?: string): void;
58
+ closeThread(orgId: string, threadId: string, agentId: string, summary: string): void;
59
+ /**
60
+ * Cross-org maintenance sweep — stays at v0.6 signature per Phase 1 plan.
61
+ * handleAgentDeparture iterates ALL orgs (internal maintenance only).
62
+ */
58
63
  handleAgentDeparture(agentId: string): void;
64
+ /**
65
+ * Cross-org sweeper — stays at v0.6 signature per Phase 1 plan.
66
+ * checkTimeouts scans ALL orgs (internal maintenance only).
67
+ */
59
68
  checkTimeouts(): void;
60
- getThread(threadId: string): Thread | null;
61
- getThreadWithMessages(threadId: string): {
69
+ getThread(orgId: string, threadId: string): Thread | null;
70
+ getThreadWithMessages(orgId: string, threadId: string): {
62
71
  thread: Thread;
63
72
  messages: ThreadMessage[];
64
73
  } | null;
65
- listThreads(filters: {
74
+ listThreads(orgId: string, filters: {
66
75
  status?: string;
67
76
  agent_id?: string;
68
77
  module?: string;
@@ -83,15 +92,20 @@ export declare class Consultation {
83
92
  */
84
93
  since_minutes?: number;
85
94
  }): Thread[];
86
- getThreadUpdates(agentId: string, since?: string): ThreadMessage[];
87
- logActionSummary(params: {
95
+ getThreadUpdates(orgId: string, agentId: string, since?: string): ThreadMessage[];
96
+ logActionSummary(orgId: string, params: {
88
97
  session_id: string;
89
98
  agent_id: string;
90
99
  file_path?: string;
91
100
  summary: string;
92
101
  }): ActionSummary;
93
- getActionSummaries(agentId: string, since?: string): ActionSummary[];
94
- getActionSummariesBySession(sessionId: string): ActionSummary[];
102
+ getActionSummaries(orgId: string, agentId: string, since?: string): ActionSummary[];
103
+ getActionSummariesBySession(orgId: string, sessionId: string): ActionSummary[];
104
+ /**
105
+ * Cross-org thread lookup for internal sweepers/departure handlers.
106
+ * Do NOT call from public methods — use getThread(orgId, id) instead.
107
+ */
108
+ private getThreadCrossOrg;
95
109
  private postResolutionMessage;
96
110
  private allRespondentsApproved;
97
111
  }