@imenam/mcp-organizer 1.0.2 → 1.0.7

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 (50) hide show
  1. package/README.md +609 -548
  2. package/dist/shared/schemas/admin-api.schema.d.ts +101 -0
  3. package/dist/shared/schemas/admin-api.schema.js +39 -0
  4. package/dist/shared/schemas/agent-api.schema.d.ts +2 -0
  5. package/dist/shared/schemas/agent-api.schema.js +4 -0
  6. package/dist/shared/schemas/agent-tools.schema.d.ts +40 -1
  7. package/dist/shared/schemas/agent-tools.schema.js +8 -1
  8. package/dist/shared/schemas/api.schema.d.ts +2 -0
  9. package/dist/shared/schemas/api.schema.js +2 -0
  10. package/dist/shared/schemas/entities.schema.d.ts +27 -1
  11. package/dist/shared/schemas/entities.schema.js +21 -2
  12. package/dist/shared/schemas/notifications-api.schema.d.ts +31 -0
  13. package/dist/shared/schemas/notifications-api.schema.js +22 -1
  14. package/dist/src/agent-api/notifications.js +1 -0
  15. package/dist/src/agent-api/requests.js +2 -1
  16. package/dist/src/cli/set-admin.d.ts +1 -0
  17. package/dist/src/cli/set-admin.js +45 -0
  18. package/dist/src/cli/watch.d.ts +0 -3
  19. package/dist/src/cli/watch.js +1 -9
  20. package/dist/src/core/accounts.js +2 -1
  21. package/dist/src/core/admin.d.ts +24 -0
  22. package/dist/src/core/admin.js +85 -0
  23. package/dist/src/core/agent-tokens.d.ts +9 -1
  24. package/dist/src/core/agent-tokens.js +25 -2
  25. package/dist/src/core/agent.d.ts +5 -0
  26. package/dist/src/core/agent.js +23 -0
  27. package/dist/src/core/migrations.js +369 -344
  28. package/dist/src/core/notifications.d.ts +7 -1
  29. package/dist/src/core/notifications.js +32 -1
  30. package/dist/src/core/requests.d.ts +1 -1
  31. package/dist/src/core/requests.js +4 -4
  32. package/dist/src/core/store.d.ts +38 -2
  33. package/dist/src/core/store.js +161 -38
  34. package/dist/src/gui-api.d.ts +6 -0
  35. package/dist/src/gui-api.js +25 -4
  36. package/dist/src/index.js +8 -1
  37. package/dist/src/mcp/notifications.d.ts +22 -5
  38. package/dist/src/mcp/notifications.js +95 -9
  39. package/dist/src/mcp/start.d.ts +1 -1
  40. package/dist/src/mcp/start.js +39 -32
  41. package/dist/src/mcp/tools.js +1 -1
  42. package/dist/src/server/runner.d.ts +51 -0
  43. package/dist/src/server/runner.js +222 -0
  44. package/dist/src/server/start.js +8 -0
  45. package/gui/dist/assets/index-DuoSNpBr.js +70 -0
  46. package/gui/dist/assets/index-duxL0fvO.css +1 -0
  47. package/gui/dist/index.html +2 -2
  48. package/package.json +3 -2
  49. package/gui/dist/assets/index-BVN0agCc.css +0 -1
  50. package/gui/dist/assets/index-DI1_kr81.js +0 -70
@@ -1,13 +1,30 @@
1
+ import { type AgentRequestSummary } from "../../shared/schemas/agent-tools.schema.js";
1
2
  import type { OrganizerSiteClient, SiteConnection } from "./client.js";
2
- import type { ToolDefinition } from "./tools.js";
3
+ import { type ToolDefinition } from "./tools.js";
3
4
  /**
4
- * Outil `enable_notifications`, exécuté par le mode MCP lui-même : il lit le
5
- * jeton, l'espace et l'état du veilleur sur le site, dépose le relais du
6
- * veilleur et rend l'appel `Monitor` que l'agent doit faire.
5
+ * Outils de notification, exécutés par le mode MCP lui-même.
6
+ *
7
+ * `enable_notifications` fonctionne de deux façons. En local, il dépose le
8
+ * relais du veilleur et rend l'appel `Monitor` qui lance `--watch`. Derrière
9
+ * le relais de mcp-http-gateway, qui remplit l'argument réservé
10
+ * `_gateway_watch`, il rend une spécification de veille
11
+ * (`_meta["gateway/watch"]`) : le relais lance alors son propre veilleur, qui
12
+ * appelle `wait_for_requests` en boucle à travers la gateway.
7
13
  */
14
+ /** Clés de la convention de veille de mcp-http-gateway (voir son gateway-watch.js). */
15
+ export declare const WATCH_ARG = "_gateway_watch";
16
+ /** Résultat d'un outil de notification : texte pour l'agent et métadonnées pour le relais. */
17
+ export interface NotificationToolResult {
18
+ text: string;
19
+ meta?: Record<string, unknown>;
20
+ }
21
+ /** Ligne d'une demande : une seule ligne, lisible telle quelle dans la notification. */
22
+ export declare function formatRequestEvent(request: AgentRequestSummary): string;
8
23
  /** Durée maximale d'un `Monitor` côté hôte. */
9
24
  export declare const MONITOR_TIMEOUT_MS = 1800000;
10
25
  export declare const ENABLE_NOTIFICATIONS_TOOL: ToolDefinition;
26
+ export declare const WAIT_FOR_REQUESTS_TOOL: ToolDefinition;
11
27
  /** Commande du veilleur du jeton `tokenId`. */
12
28
  export declare function watchCommand(tokenId: string): string;
13
- export declare function enableNotifications(client: OrganizerSiteClient, connection: SiteConnection): Promise<string>;
29
+ export declare function enableNotifications(client: OrganizerSiteClient, connection: SiteConnection, args: Record<string, unknown>): Promise<NotificationToolResult>;
30
+ export declare function waitForRequests(client: OrganizerSiteClient, args: Record<string, unknown>): Promise<NotificationToolResult>;
@@ -1,35 +1,100 @@
1
- import { NotificationsDataSchema } from "../../shared/schemas/agent-tools.schema.js";
1
+ import { MAX_WAIT_SECONDS, NotificationsDataSchema } from "../../shared/schemas/agent-tools.schema.js";
2
+ import { ToolArgumentError } from "./tools.js";
2
3
  import { writeHandoff } from "./watch-handoff.js";
3
4
  /**
4
- * Outil `enable_notifications`, exécuté par le mode MCP lui-même : il lit le
5
- * jeton, l'espace et l'état du veilleur sur le site, dépose le relais du
6
- * veilleur et rend l'appel `Monitor` que l'agent doit faire.
5
+ * Outils de notification, exécutés par le mode MCP lui-même.
6
+ *
7
+ * `enable_notifications` fonctionne de deux façons. En local, il dépose le
8
+ * relais du veilleur et rend l'appel `Monitor` qui lance `--watch`. Derrière
9
+ * le relais de mcp-http-gateway, qui remplit l'argument réservé
10
+ * `_gateway_watch`, il rend une spécification de veille
11
+ * (`_meta["gateway/watch"]`) : le relais lance alors son propre veilleur, qui
12
+ * appelle `wait_for_requests` en boucle à travers la gateway.
7
13
  */
14
+ /** Clés de la convention de veille de mcp-http-gateway (voir son gateway-watch.js). */
15
+ export const WATCH_ARG = "_gateway_watch";
16
+ const WATCH_META = "gateway/watch";
17
+ const EVENTS_META = "gateway/events";
18
+ /** Longueur maximale de l'extrait du texte d'une demande dans sa ligne. */
19
+ const EXCERPT_LENGTH = 120;
20
+ /** Ligne d'une demande : une seule ligne, lisible telle quelle dans la notification. */
21
+ export function formatRequestEvent(request) {
22
+ const text = request.text.replace(/\s+/g, " ").trim();
23
+ const excerpt = text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 1)}…` : text;
24
+ const context = request.contextTask ? ` · ${request.contextTask.key}` : "";
25
+ return `[organizer] ${request.kind} · ${request.author.name} — "${excerpt}"${context} (${request.id})`;
26
+ }
8
27
  /** Durée maximale d'un `Monitor` côté hôte. */
9
28
  export const MONITOR_TIMEOUT_MS = 1_800_000;
10
29
  const PACKAGE_NAME = "@imenam/mcp-organizer";
11
30
  export const ENABLE_NOTIFICATIONS_TOOL = {
12
31
  name: "enable_notifications",
13
32
  description: "Get notified in real time of the requests the members of your space send to the AI, without polling the queue. Returns immediately: it blocks nothing and starts nothing on its own. It hands you the exact Monitor call to make right after — arming that watcher is what actually delivers notifications, and until you do it no request will reach you. Call this once at the start of a session; then keep working, each new pending request interrupts you on its own. The host stops the watcher after 30 minutes at most: re-arm it with the same Monitor call each time it expires.",
14
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: {
36
+ [WATCH_ARG]: {
37
+ type: "object",
38
+ description: "Filled in by the mcp-http-gateway relay, never by the agent: the relay will run the watcher and needs a watch specification instead of a local command.",
39
+ },
40
+ },
41
+ additionalProperties: false,
42
+ },
43
+ };
44
+ export const WAIT_FOR_REQUESTS_TOOL = {
45
+ name: "wait_for_requests",
46
+ description: "Block until at least one request of your space becomes pending (created, or released by a member), or until the timeout. Returns the new pending requests (none on timeout) and a cursor: pass it to the next call to get exactly what became pending since. It is the waiting tool of the notification watcher; to stay reachable, prefer enable_notifications.",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ cursor: {
51
+ type: "string",
52
+ description: "Cursor returned by the previous call. Without it, only requests that become pending after the call starts are returned.",
53
+ },
54
+ timeout_seconds: { type: "integer", minimum: 1, maximum: MAX_WAIT_SECONDS, description: `Maximum wait, in seconds (1-${MAX_WAIT_SECONDS}, defaults to 30).` },
55
+ watcher: {
56
+ type: "boolean",
57
+ description: "Set by the notification watcher: the wait counts as the presence of this token's watcher, shown as listening in the web app.",
58
+ },
59
+ },
60
+ additionalProperties: false,
61
+ },
15
62
  };
16
63
  /** Commande du veilleur du jeton `tokenId`. */
17
64
  export function watchCommand(tokenId) {
18
65
  return `npx -y ${PACKAGE_NAME} --watch ${tokenId}`;
19
66
  }
20
- export async function enableNotifications(client, connection) {
67
+ export async function enableNotifications(client, connection, args) {
68
+ for (const name of Object.keys(args)) {
69
+ if (name !== WATCH_ARG)
70
+ throw new ToolArgumentError(`Invalid arguments — ${name}: enable_notifications takes no argument.`);
71
+ }
21
72
  const envelope = await client.envelope({ method: "GET", path: "notifications", query: {}, body: undefined });
22
- const { token, space, pendingCount, watcher } = client.parseData(NotificationsDataSchema, envelope.data);
73
+ const { token, space, pendingCount, watcher, cursor } = client.parseData(NotificationsDataSchema, envelope.data);
74
+ if (args[WATCH_ARG] !== undefined) {
75
+ return {
76
+ text: `Watch specification for the mcp-http-gateway relay: requests of space "${space.name}" for token "${token.name}".`,
77
+ meta: {
78
+ [WATCH_META]: {
79
+ tool: WAIT_FOR_REQUESTS_TOOL.name,
80
+ arguments: { watcher: true },
81
+ cursor,
82
+ description: "New requests for the AI in the organizer",
83
+ },
84
+ },
85
+ };
86
+ }
23
87
  writeHandoff({ tokenId: token.id, url: connection.url, token: connection.token });
24
88
  const command = watchCommand(token.id);
25
89
  if (watcher.armed) {
26
- return [
90
+ const text = [
27
91
  `Notifications are already live for token "${token.name}" on space "${space.name}" (watcher armed since ${watcher.since}).`,
28
92
  "Nothing to do: keep working, new requests will interrupt you on their own.",
29
93
  "Do not arm a second watcher — you would get every request twice.",
30
94
  "Only exception: if the host just told you your watcher expired or stopped, re-arm it",
31
95
  `with the same Monitor call (command \`${command}\`, timeout_ms ${MONITOR_TIMEOUT_MS}).`,
32
96
  ].join("\n");
97
+ return { text };
33
98
  }
34
99
  const lines = [
35
100
  `Notifications enabled for token "${token.name}" on space "${space.name}".`,
@@ -50,5 +115,26 @@ export async function enableNotifications(client, connection) {
50
115
  if (pendingCount > 0) {
51
116
  lines.push("", `${pendingCount} request(s) are already pending. Only requests that become pending AFTER the`, "watcher starts are announced — process the backlog with list_requests.");
52
117
  }
53
- return lines.join("\n");
118
+ return { text: lines.join("\n") };
119
+ }
120
+ export async function waitForRequests(client, args) {
121
+ for (const name of Object.keys(args)) {
122
+ if (!(name in WAIT_FOR_REQUESTS_TOOL.inputSchema.properties))
123
+ throw new ToolArgumentError(`Invalid arguments — ${name}: unknown argument. Check the tool's input schema.`);
124
+ }
125
+ const { cursor, timeout_seconds: timeout, watcher } = args;
126
+ if (cursor !== undefined && typeof cursor !== "string")
127
+ throw new ToolArgumentError("Invalid arguments — cursor: expected the string returned by the previous call.");
128
+ if (timeout !== undefined && (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > MAX_WAIT_SECONDS)) {
129
+ throw new ToolArgumentError(`Invalid arguments — timeout_seconds: expected an integer from 1 to ${MAX_WAIT_SECONDS}.`);
130
+ }
131
+ if (watcher !== undefined && typeof watcher !== "boolean")
132
+ throw new ToolArgumentError("Invalid arguments — watcher: expected a boolean.");
133
+ const seconds = timeout ?? 30;
134
+ const result = await client.waitForRequests({ timeout_seconds: seconds, since: cursor, watcher: watcher === true });
135
+ const lines = result.requests.map(formatRequestEvent);
136
+ const text = lines.length === 0
137
+ ? `No new pending request in "${result.space.name}" after ${seconds}s. Cursor: ${result.cursor}`
138
+ : `${lines.length} new pending request(s) in "${result.space.name}":\n${lines.join("\n")}\nCursor: ${result.cursor}\n\nNext: call claim_request with the id of the oldest request.`;
139
+ return { text, meta: { [EVENTS_META]: { cursor: result.cursor, lines } } };
54
140
  }
@@ -1,6 +1,6 @@
1
1
  import { type SiteConnection } from "./client.js";
2
2
  /** Présentation du serveur transmise au client MCP : concepts et déroulé. */
3
- export declare const INSTRUCTIONS = "mcp-organizer connects you to an organizer site: a multi-user task manager where people organize tasks in a web app and queue requests for the AI. You, the connected agent, process those requests when the user asks you to. The site never calls an LLM: you are the intelligence.\n\nYour token covers exactly one space, with the rights of the user who created the token. Every tool works on that space.\n\nConcepts:\n- A **space** holds tasks and an idea box. A user's **personal space** is private; a **shared space** has one owner and members.\n- A **task** has a title, a markdown description, a status (todo, doing, review, done), a priority (low, medium, high, critical), assignees and tags. Tasks nest on two levels: task > sub-task > sub-sub-task. A task is either standard (with a checklist) or a test (with checkpoints that each tester marks ok, ko, blocked or untested).\n- A **request** is something a member of your space asked the AI: an idea, a remark, a test review, a message or an idea promotion. It is pending, then processing once you claim it (only your token can then act for it), then done (with your reply) or failed (with the reason).\n- A **batch** is the set of actions you perform for one request. Every action is logged with its before and after state and with your token, and users can undo one action or the whole batch.\n- The **scope** of your work is the space of your token: you see and process the requests of all its members, and you never act outside it.\n\nWork loop, when the user asks you to process the queue:\n1. list_requests to see the pending requests of your space.\n2. claim_request on the oldest one: it becomes processing for you and you get its context (text, author, context task, task tree, members, recent ideas). get_context shows the space at any time.\n3. Read what you need with get_task and search_tasks.\n4. Act with create_task, update_task, set_assignees, set_tags, edit_checklist, add_comment, create_idea, update_idea, promote_idea \u2014 always passing the request_id. Each write is one undoable action of the batch.\n5. complete_request with a short reply in the author's language (or fail_request with the reason), then go back to step 1 until no request is pending.\nWhen the queue is empty, tell the user.\n\nNotifications: call enable_notifications once at the start of the session and arm the watcher it hands you with Monitor. Each request that becomes pending in your space then arrives on its own as one line (kind, author, excerpt, request id), even while you are idle: process it with the work loop above, starting with claim_request. The host stops that watcher after 30 minutes at most: each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good. A line \"notifications stopped\" means the site refused the token: tell the user.\n\nHow to handle each kind of request:\n- **idea**: classify it. If it belongs to an existing task or sub-task, attach it there (sub-task, checklist item or comment). If it is actionable and important enough, create a task (or sub-task) with a fitting priority. Otherwise, put it in the idea box with create_idea. Prefer existing tasks over duplicates.\n- **test_review**: feed the tester's findings back into the existing tasks (status, checklist, comments, priority of the affected tasks), or create a task for each problem that has none.\n- **remark** and **message**: do whatever is useful \u2014 answer, update or create tasks, comment \u2014 and say what you did in the reply.\n- **idea_promotion**: turn the context idea into a well-formed task with promote_idea.\n\nRules: write titles, descriptions and replies in the language of the users; keep the reply short and factual. Errors tell you what to do next; an error about the token (missing, revoked, expired, owner no longer a member) cannot be fixed by retrying: tell the user.";
3
+ export declare const INSTRUCTIONS = "mcp-organizer connects you to an organizer site: a multi-user task manager where people organize tasks in a web app and queue requests for the AI. You, the connected agent, process those requests when the user asks you to. The site never calls an LLM: you are the intelligence.\n\nYour token covers exactly one space, with the rights of the user who created the token. Every tool works on that space.\n\nConcepts:\n- A **space** holds tasks and an idea box. A user's **personal space** is private; a **shared space** has one owner and members.\n- A **task** has a title, a markdown description, a status (todo, doing, review, done), a priority (low, medium, high, critical), assignees and tags. Tasks nest on two levels: task > sub-task > sub-sub-task. A task is either standard (with a checklist) or a test (with checkpoints that each tester marks ok, ko, blocked or untested).\n- A **request** is something a member of your space asked the AI: an idea, a remark, a test review, a message or an idea promotion. It is pending, then processing once you claim it (only your token can then act for it), then done (with your reply) or failed (with the reason).\n- A **batch** is the set of actions you perform for one request. Every action is logged with its before and after state and with your token, and users can undo one action or the whole batch.\n- The **scope** of your work is the space of your token: you see and process the requests of all its members, and you never act outside it.\n\nWork loop, when the user asks you to process the queue:\n1. list_requests to see the pending requests of your space.\n2. claim_request on the oldest one: it becomes processing for you and you get its context (text, author, context task, for a message the previous exchanges of its author, task tree, members, recent ideas). get_context shows the space at any time.\n3. Read what you need with get_task and search_tasks.\n4. Act with create_task, update_task, set_assignees, set_tags, edit_checklist, add_comment, create_idea, update_idea, promote_idea \u2014 always passing the request_id. Each write is one undoable action of the batch.\n5. complete_request with a short reply in the author's language (or fail_request with the reason), then go back to step 1 until no request is pending.\nWhen the queue is empty, tell the user.\n\nNotifications: call enable_notifications once at the start of the session and arm the watcher it hands you with Monitor, whatever the command (behind mcp-http-gateway, the gateway relay answers in place of this server and gives its own watcher command). Each request that becomes pending in your space then arrives on its own as one line (kind, author, excerpt, request id), even while you are idle: process it with the work loop above, starting with claim_request. The host stops that watcher after 30 minutes at most: each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good. A line \"notifications stopped\" means the site refused the token, and a line \"[gateway-watch] \u2026 la veille \u00E9choue\" means the gateway watcher keeps failing: tell the user. wait_for_requests is the waiting tool of the watcher: you do not need to call it.\n\nHow to handle each kind of request:\n- **idea**: classify it. If it belongs to an existing task or sub-task, attach it there (sub-task, checklist item or comment). If it is actionable and important enough, create a task (or sub-task) with a fitting priority. Otherwise, put it in the idea box with create_idea. Prefer existing tasks over duplicates.\n- **test_review**: feed the tester's findings back into the existing tasks (status, checklist, comments, priority of the affected tasks), or create a task for each problem that has none.\n- **remark** and **message**: do whatever is useful \u2014 answer, update or create tasks, comment \u2014 and say what you did in the reply.\n- **idea_promotion**: turn the context idea into a well-formed task with promote_idea.\n\nRules: write titles, descriptions and replies in the language of the users; keep the reply short and factual. Errors tell you what to do next; an error about the token (missing, revoked, expired, owner no longer a member) cannot be fixed by retrying: tell the user.";
4
4
  /**
5
5
  * Connexion au site d'après `ORGANIZER_URL` et `ORGANIZER_TOKEN` ; une valeur
6
6
  * absente ou invalide, ou une URL http vers une autre machine, est une `ConfigError`.
@@ -6,36 +6,36 @@ import { createLogger, setupLogging } from "../core/logger.js";
6
6
  import { ConfigError } from "../core/errors.js";
7
7
  import { OrganizerSiteClient, SiteError } from "./client.js";
8
8
  import { AGENT_TOOLS, ToolArgumentError, findAgentTool, toolHttpRequest } from "./tools.js";
9
- import { ENABLE_NOTIFICATIONS_TOOL, enableNotifications } from "./notifications.js";
9
+ import { ENABLE_NOTIFICATIONS_TOOL, WAIT_FOR_REQUESTS_TOOL, enableNotifications, waitForRequests } from "./notifications.js";
10
10
  import { removeOwnHandoffs } from "./watch-handoff.js";
11
11
  /** Présentation du serveur transmise au client MCP : concepts et déroulé. */
12
- export const INSTRUCTIONS = `mcp-organizer connects you to an organizer site: a multi-user task manager where people organize tasks in a web app and queue requests for the AI. You, the connected agent, process those requests when the user asks you to. The site never calls an LLM: you are the intelligence.
13
-
14
- Your token covers exactly one space, with the rights of the user who created the token. Every tool works on that space.
15
-
16
- Concepts:
17
- - A **space** holds tasks and an idea box. A user's **personal space** is private; a **shared space** has one owner and members.
18
- - A **task** has a title, a markdown description, a status (todo, doing, review, done), a priority (low, medium, high, critical), assignees and tags. Tasks nest on two levels: task > sub-task > sub-sub-task. A task is either standard (with a checklist) or a test (with checkpoints that each tester marks ok, ko, blocked or untested).
19
- - A **request** is something a member of your space asked the AI: an idea, a remark, a test review, a message or an idea promotion. It is pending, then processing once you claim it (only your token can then act for it), then done (with your reply) or failed (with the reason).
20
- - A **batch** is the set of actions you perform for one request. Every action is logged with its before and after state and with your token, and users can undo one action or the whole batch.
21
- - The **scope** of your work is the space of your token: you see and process the requests of all its members, and you never act outside it.
22
-
23
- Work loop, when the user asks you to process the queue:
24
- 1. list_requests to see the pending requests of your space.
25
- 2. claim_request on the oldest one: it becomes processing for you and you get its context (text, author, context task, task tree, members, recent ideas). get_context shows the space at any time.
26
- 3. Read what you need with get_task and search_tasks.
27
- 4. Act with create_task, update_task, set_assignees, set_tags, edit_checklist, add_comment, create_idea, update_idea, promote_idea — always passing the request_id. Each write is one undoable action of the batch.
28
- 5. complete_request with a short reply in the author's language (or fail_request with the reason), then go back to step 1 until no request is pending.
29
- When the queue is empty, tell the user.
30
-
31
- Notifications: call enable_notifications once at the start of the session and arm the watcher it hands you with Monitor. Each request that becomes pending in your space then arrives on its own as one line (kind, author, excerpt, request id), even while you are idle: process it with the work loop above, starting with claim_request. The host stops that watcher after 30 minutes at most: each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good. A line "notifications stopped" means the site refused the token: tell the user.
32
-
33
- How to handle each kind of request:
34
- - **idea**: classify it. If it belongs to an existing task or sub-task, attach it there (sub-task, checklist item or comment). If it is actionable and important enough, create a task (or sub-task) with a fitting priority. Otherwise, put it in the idea box with create_idea. Prefer existing tasks over duplicates.
35
- - **test_review**: feed the tester's findings back into the existing tasks (status, checklist, comments, priority of the affected tasks), or create a task for each problem that has none.
36
- - **remark** and **message**: do whatever is useful — answer, update or create tasks, comment — and say what you did in the reply.
37
- - **idea_promotion**: turn the context idea into a well-formed task with promote_idea.
38
-
12
+ export const INSTRUCTIONS = `mcp-organizer connects you to an organizer site: a multi-user task manager where people organize tasks in a web app and queue requests for the AI. You, the connected agent, process those requests when the user asks you to. The site never calls an LLM: you are the intelligence.
13
+
14
+ Your token covers exactly one space, with the rights of the user who created the token. Every tool works on that space.
15
+
16
+ Concepts:
17
+ - A **space** holds tasks and an idea box. A user's **personal space** is private; a **shared space** has one owner and members.
18
+ - A **task** has a title, a markdown description, a status (todo, doing, review, done), a priority (low, medium, high, critical), assignees and tags. Tasks nest on two levels: task > sub-task > sub-sub-task. A task is either standard (with a checklist) or a test (with checkpoints that each tester marks ok, ko, blocked or untested).
19
+ - A **request** is something a member of your space asked the AI: an idea, a remark, a test review, a message or an idea promotion. It is pending, then processing once you claim it (only your token can then act for it), then done (with your reply) or failed (with the reason).
20
+ - A **batch** is the set of actions you perform for one request. Every action is logged with its before and after state and with your token, and users can undo one action or the whole batch.
21
+ - The **scope** of your work is the space of your token: you see and process the requests of all its members, and you never act outside it.
22
+
23
+ Work loop, when the user asks you to process the queue:
24
+ 1. list_requests to see the pending requests of your space.
25
+ 2. claim_request on the oldest one: it becomes processing for you and you get its context (text, author, context task, for a message the previous exchanges of its author, task tree, members, recent ideas). get_context shows the space at any time.
26
+ 3. Read what you need with get_task and search_tasks.
27
+ 4. Act with create_task, update_task, set_assignees, set_tags, edit_checklist, add_comment, create_idea, update_idea, promote_idea — always passing the request_id. Each write is one undoable action of the batch.
28
+ 5. complete_request with a short reply in the author's language (or fail_request with the reason), then go back to step 1 until no request is pending.
29
+ When the queue is empty, tell the user.
30
+
31
+ Notifications: call enable_notifications once at the start of the session and arm the watcher it hands you with Monitor, whatever the command (behind mcp-http-gateway, the gateway relay answers in place of this server and gives its own watcher command). Each request that becomes pending in your space then arrives on its own as one line (kind, author, excerpt, request id), even while you are idle: process it with the work loop above, starting with claim_request. The host stops that watcher after 30 minutes at most: each time it tells you the watcher expired, re-arm it at once with the same Monitor call; otherwise notifications stop for good. A line "notifications stopped" means the site refused the token, and a line "[gateway-watch] … la veille échoue" means the gateway watcher keeps failing: tell the user. wait_for_requests is the waiting tool of the watcher: you do not need to call it.
32
+
33
+ How to handle each kind of request:
34
+ - **idea**: classify it. If it belongs to an existing task or sub-task, attach it there (sub-task, checklist item or comment). If it is actionable and important enough, create a task (or sub-task) with a fitting priority. Otherwise, put it in the idea box with create_idea. Prefer existing tasks over duplicates.
35
+ - **test_review**: feed the tester's findings back into the existing tasks (status, checklist, comments, priority of the affected tasks), or create a task for each problem that has none.
36
+ - **remark** and **message**: do whatever is useful — answer, update or create tasks, comment — and say what you did in the reply.
37
+ - **idea_promotion**: turn the context idea into a well-formed task with promote_idea.
38
+
39
39
  Rules: write titles, descriptions and replies in the language of the users; keep the reply short and factual. Errors tell you what to do next; an error about the token (missing, revoked, expired, owner no longer a member) cannot be fixed by retrying: tell the user.`;
40
40
  /** Hôtes de la machine locale, seuls admis en http. */
41
41
  const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
@@ -84,15 +84,22 @@ export async function runMcpMode() {
84
84
  const logger = createLogger("mcp");
85
85
  const client = new OrganizerSiteClient(connection);
86
86
  const server = new Server({ name: "mcp-organizer", version: APP_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
87
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...AGENT_TOOLS.map((tool) => tool.definition), ENABLE_NOTIFICATIONS_TOOL] }));
87
+ // Outils exécutés par le mode MCP lui-même : leur résultat peut porter des métadonnées pour le relais de mcp-http-gateway.
88
+ const notificationTools = {
89
+ [ENABLE_NOTIFICATIONS_TOOL.name]: (args) => enableNotifications(client, connection, args),
90
+ [WAIT_FOR_REQUESTS_TOOL.name]: (args) => waitForRequests(client, args),
91
+ };
92
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...AGENT_TOOLS.map((tool) => tool.definition), ENABLE_NOTIFICATIONS_TOOL, WAIT_FOR_REQUESTS_TOOL] }));
88
93
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
89
94
  const { name, arguments: args } = request.params;
90
- if (name === ENABLE_NOTIFICATIONS_TOOL.name) {
95
+ const notificationTool = notificationTools[name];
96
+ if (notificationTool) {
91
97
  try {
92
- return { content: [{ type: "text", text: await enableNotifications(client, connection) }] };
98
+ const result = await notificationTool((args ?? {}));
99
+ return { content: [{ type: "text", text: result.text }], ...(result.meta ? { _meta: result.meta } : {}) };
93
100
  }
94
101
  catch (error) {
95
- if (!(error instanceof SiteError))
102
+ if (!(error instanceof SiteError) && !(error instanceof ToolArgumentError))
96
103
  logger.error(`Tool ${name} failed: ${error?.stack ?? error}`);
97
104
  return errorResult(error?.message ?? String(error));
98
105
  }
@@ -35,7 +35,7 @@ export const AGENT_TOOLS = [
35
35
  path: "requests/{request_id}/claim",
36
36
  definition: {
37
37
  name: "claim_request",
38
- description: "Claim a pending request before working on it: it becomes processing for your token only (no other agent can take it or act for it) and you get its full context — text, author, context task or idea, the task tree of your space with keys, statuses, priorities and assignees, its members and recent ideas. Every write tool then needs this request_id.",
38
+ description: "Claim a pending request before working on it: it becomes processing for your token only (no other agent can take it or act for it) and you get its full context — text, author, context task or idea, for a message the previous exchanges of its author (their requests and your replies), the task tree of your space with keys, statuses, priorities and assignees, its members and recent ideas. Every write tool then needs this request_id.",
39
39
  inputSchema: {
40
40
  type: "object",
41
41
  properties: { request_id: { type: "string", description: "Id of a pending request, from list_requests." } },
@@ -0,0 +1,51 @@
1
+ import type { Logger } from "../core/logger.js";
2
+ import type { Store } from "../core/store.js";
3
+ import type { RunnerStatus } from "../../shared/schemas/admin-api.schema.js";
4
+ /** Consigne de la session. Les `instructions` du serveur MCP organizer décrivent les concepts et la boucle de travail. */
5
+ export declare const SESSION_PROMPT: string;
6
+ export interface RunnerConfig {
7
+ /** Commande de Claude Code, et ses éventuels arguments fixes. */
8
+ command: string[];
9
+ /** URL du site joignable depuis cette machine, en http local. */
10
+ siteUrl: string;
11
+ /** Point d'entrée de ce paquet, lancé en mode MCP par la session. */
12
+ entry: string;
13
+ /** Dossier de configuration et de connexion de Claude Code. */
14
+ claudeDir: string;
15
+ /** Dossier des sessions : configurations MCP éphémères, dossier courant de Claude Code. */
16
+ workDir: string;
17
+ }
18
+ /** Configuration du runner d'après l'environnement, ou la raison de sa désactivation. */
19
+ export declare function runnerFromEnv(env: NodeJS.ProcessEnv, site: {
20
+ host: string;
21
+ port: number;
22
+ rootDir: string;
23
+ dataDir: string;
24
+ }): {
25
+ status: RunnerStatus;
26
+ config: RunnerConfig | null;
27
+ };
28
+ export declare class SiteRunner {
29
+ private readonly store;
30
+ private readonly config;
31
+ private readonly logger;
32
+ private readonly queue;
33
+ private readonly rerun;
34
+ /** Espaces à traiter une fois même sans autorisation : demande ponctuelle d'un administrateur. */
35
+ private readonly forced;
36
+ private running;
37
+ private child;
38
+ private readonly unsubscribe;
39
+ constructor(store: Store, config: RunnerConfig, logger: Logger);
40
+ /** S'abonne aux demandes et aux autorisations, et planifie les espaces autorisés qui ont déjà des demandes. */
41
+ start(): void;
42
+ /** Arrête la session en cours et ne planifie plus rien. */
43
+ stop(): void;
44
+ /** Planifie une session pour traiter la file de l'espace une fois, même si le runner n'y est pas autorisé. */
45
+ runNow(spaceId: string): void;
46
+ /** Planifie une session pour l'espace ; un espace en cours de session est repris après elle. */
47
+ schedule(spaceId: string): void;
48
+ private drain;
49
+ private runSession;
50
+ private spawnClaude;
51
+ }
@@ -0,0 +1,222 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { issueRunnerToken } from "../core/agent-tokens.js";
6
+ import { upstreamAddress } from "../deploy/templates.js";
7
+ /**
8
+ * Runner du site : une session Claude Code (`claude -p`) par espace autorisé
9
+ * qui a des demandes en attente.
10
+ *
11
+ * Le site lance lui-même la session, sous son propre compte, quand une
12
+ * demande passe en attente dans un espace où un administrateur a autorisé le
13
+ * runner, quand un espace est autorisé alors que des demandes y attendent, et
14
+ * au démarrage ; un administrateur peut aussi faire traiter la file d'un espace
15
+ * ponctuellement, qu'il soit autorisé ou non. La session reçoit une configuration MCP qui lui est propre :
16
+ * le mode MCP de ce paquet, relié au site local par le jeton du runner de
17
+ * l'espace, dont la valeur est renouvelée avant la session et invalidée après.
18
+ * Elle n'a accès qu'aux outils organizer : ni shell, ni fichiers, ni autre
19
+ * serveur MCP. Une seule session tourne à la fois.
20
+ *
21
+ * `ORGANIZER_RUNNER_CLAUDE` donne la commande de Claude Code ; sans elle, le
22
+ * runner est désactivé et le dit. Claude Code garde sa configuration et sa
23
+ * connexion dans `<données>/claude`.
24
+ */
25
+ /** Durée maximale d'une session. */
26
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
27
+ /** Nom du serveur MCP organizer dans la configuration d'une session. */
28
+ const MCP_SERVER_NAME = "organizer";
29
+ /** Outils intégrés de Claude Code refusés à la session : elle n'agit que par les outils organizer. */
30
+ const DISALLOWED_TOOLS = [
31
+ "Bash",
32
+ "BashOutput",
33
+ "KillShell",
34
+ "Read",
35
+ "Write",
36
+ "Edit",
37
+ "NotebookEdit",
38
+ "Glob",
39
+ "Grep",
40
+ "WebFetch",
41
+ "WebSearch",
42
+ "Task",
43
+ "TodoWrite",
44
+ ];
45
+ /** Consigne de la session. Les `instructions` du serveur MCP organizer décrivent les concepts et la boucle de travail. */
46
+ export const SESSION_PROMPT = [
47
+ "You are the organizer runner: process the pending requests of your organizer space with the organizer tools.",
48
+ "Call list_requests, then claim and process each pending request with the work loop of the server instructions, until list_requests reports no pending request. Then stop.",
49
+ "Do not call enable_notifications or wait_for_requests.",
50
+ ].join("\n");
51
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0", "::"]);
52
+ /** Configuration du runner d'après l'environnement, ou la raison de sa désactivation. */
53
+ export function runnerFromEnv(env, site) {
54
+ const raw = env.ORGANIZER_RUNNER_CLAUDE?.trim();
55
+ if (!raw) {
56
+ return {
57
+ status: { enabled: false, disabledReason: "ORGANIZER_RUNNER_CLAUDE n'est pas défini : indiquez la commande de Claude Code (par exemple /usr/bin/claude) dans le fichier .env du site." },
58
+ config: null,
59
+ };
60
+ }
61
+ if (!LOCAL_HOSTS.has(site.host)) {
62
+ return {
63
+ status: { enabled: false, disabledReason: `HOST (${site.host}) n'est pas une adresse locale : le runner joint le site en http sur cette machine ; écoutez sur 127.0.0.1 derrière nginx.` },
64
+ config: null,
65
+ };
66
+ }
67
+ return {
68
+ status: { enabled: true, disabledReason: null },
69
+ config: {
70
+ command: raw.split(/\s+/),
71
+ siteUrl: `http://${upstreamAddress(site.host, site.port)}`,
72
+ entry: path.join(site.rootDir, "dist", "src", "index.js"),
73
+ claudeDir: path.join(site.dataDir, "claude"),
74
+ workDir: path.join(site.dataDir, "runner"),
75
+ },
76
+ };
77
+ }
78
+ /** Environnement de la session : le strict nécessaire, sans les secrets du site (SMTP, Groq…). */
79
+ function sessionEnv(config) {
80
+ const env = { HOME: config.claudeDir, CLAUDE_CONFIG_DIR: config.claudeDir };
81
+ for (const name of ["PATH", "LANG", "TMPDIR", "TEMP", "TMP", "SystemRoot", "SYSTEMROOT"]) {
82
+ if (process.env[name] !== undefined)
83
+ env[name] = process.env[name];
84
+ }
85
+ return env;
86
+ }
87
+ export class SiteRunner {
88
+ store;
89
+ config;
90
+ logger;
91
+ queue = [];
92
+ rerun = new Set();
93
+ /** Espaces à traiter une fois même sans autorisation : demande ponctuelle d'un administrateur. */
94
+ forced = new Set();
95
+ running = null;
96
+ child = null;
97
+ unsubscribe = [];
98
+ constructor(store, config, logger) {
99
+ this.store = store;
100
+ this.config = config;
101
+ this.logger = logger;
102
+ }
103
+ /** S'abonne aux demandes et aux autorisations, et planifie les espaces autorisés qui ont déjà des demandes. */
104
+ start() {
105
+ fs.mkdirSync(this.config.claudeDir, { recursive: true, mode: 0o700 });
106
+ fs.mkdirSync(this.config.workDir, { recursive: true, mode: 0o700 });
107
+ this.unsubscribe.push(this.store.onPendingRequest((request) => {
108
+ if (this.store.findSpace(request.targetSpaceId)?.runnerAllowed)
109
+ this.schedule(request.targetSpaceId);
110
+ }), this.store.onRunnerAllowed((space) => this.schedule(space.id)));
111
+ for (const space of this.store.listAllSpaces())
112
+ if (space.runnerAllowed)
113
+ this.schedule(space.id);
114
+ this.logger.info(`Runner enabled: Claude Code sessions with ${this.config.command[0]} (configuration in ${this.config.claudeDir})`);
115
+ }
116
+ /** Arrête la session en cours et ne planifie plus rien. */
117
+ stop() {
118
+ for (const unsubscribe of this.unsubscribe.splice(0))
119
+ unsubscribe();
120
+ this.queue.length = 0;
121
+ this.child?.kill();
122
+ }
123
+ /** Planifie une session pour traiter la file de l'espace une fois, même si le runner n'y est pas autorisé. */
124
+ runNow(spaceId) {
125
+ this.forced.add(spaceId);
126
+ this.schedule(spaceId);
127
+ }
128
+ /** Planifie une session pour l'espace ; un espace en cours de session est repris après elle. */
129
+ schedule(spaceId) {
130
+ if (this.running === spaceId) {
131
+ this.rerun.add(spaceId);
132
+ return;
133
+ }
134
+ if (!this.queue.includes(spaceId))
135
+ this.queue.push(spaceId);
136
+ void this.drain();
137
+ }
138
+ async drain() {
139
+ if (this.running !== null)
140
+ return;
141
+ for (let spaceId = this.queue.shift(); spaceId !== undefined; spaceId = this.queue.shift()) {
142
+ this.running = spaceId;
143
+ try {
144
+ await this.runSession(spaceId);
145
+ }
146
+ catch (err) {
147
+ this.logger.error(`Runner session for space ${spaceId} failed: ${err?.stack ?? err}`);
148
+ }
149
+ finally {
150
+ this.running = null;
151
+ }
152
+ if (this.rerun.delete(spaceId) && !this.queue.includes(spaceId))
153
+ this.queue.push(spaceId);
154
+ }
155
+ }
156
+ async runSession(spaceId) {
157
+ const space = this.store.findSpace(spaceId);
158
+ const forced = this.forced.delete(spaceId);
159
+ if (!space || (!space.runnerAllowed && !forced) || this.store.countPendingRequestsOfSpace(spaceId) === 0)
160
+ return;
161
+ const token = issueRunnerToken(this.store, spaceId);
162
+ const configFile = path.join(this.config.workDir, `session-${randomUUID()}.json`);
163
+ const mcpConfig = {
164
+ mcpServers: {
165
+ [MCP_SERVER_NAME]: { command: process.execPath, args: [this.config.entry], env: { ORGANIZER_URL: this.config.siteUrl, ORGANIZER_TOKEN: token } },
166
+ },
167
+ };
168
+ fs.writeFileSync(configFile, JSON.stringify(mcpConfig), { encoding: "utf-8", mode: 0o600 });
169
+ try {
170
+ await this.spawnClaude(space.name, configFile);
171
+ }
172
+ finally {
173
+ fs.rmSync(configFile, { force: true });
174
+ issueRunnerToken(this.store, spaceId);
175
+ }
176
+ }
177
+ spawnClaude(spaceName, configFile) {
178
+ const [command, ...fixed] = this.config.command;
179
+ const args = [
180
+ ...fixed,
181
+ "-p",
182
+ SESSION_PROMPT,
183
+ "--mcp-config",
184
+ configFile,
185
+ "--strict-mcp-config",
186
+ "--allowedTools",
187
+ `mcp__${MCP_SERVER_NAME}`,
188
+ "--disallowedTools",
189
+ DISALLOWED_TOOLS.join(","),
190
+ ];
191
+ const label = `Runner [${spaceName}]`;
192
+ this.logger.info(`${label}: session started`);
193
+ return new Promise((resolve) => {
194
+ const child = spawn(command, args, { cwd: this.config.workDir, env: sessionEnv(this.config), stdio: ["ignore", "pipe", "pipe"] });
195
+ this.child = child;
196
+ const forward = (stream) => stream?.on("data", (chunk) => {
197
+ for (const line of String(chunk).split(/\r?\n/))
198
+ if (line.trim())
199
+ this.logger.info(`${label}: ${line}`);
200
+ });
201
+ forward(child.stdout);
202
+ forward(child.stderr);
203
+ const timer = setTimeout(() => {
204
+ this.logger.warn(`${label}: session still running after ${SESSION_TIMEOUT_MS / 60000} min, stopped`);
205
+ child.kill();
206
+ }, SESSION_TIMEOUT_MS);
207
+ const done = () => {
208
+ clearTimeout(timer);
209
+ this.child = null;
210
+ resolve();
211
+ };
212
+ child.once("error", (err) => {
213
+ this.logger.error(`${label}: cannot start ${command} (${err.message}): check ORGANIZER_RUNNER_CLAUDE`);
214
+ done();
215
+ });
216
+ child.once("exit", (code) => {
217
+ this.logger.info(`${label}: session ended (exit code ${code})`);
218
+ done();
219
+ });
220
+ });
221
+ }
222
+ }
@@ -13,6 +13,7 @@ import { transcriberFromEnv } from "../core/transcription.js";
13
13
  import { ConfigError } from "../core/errors.js";
14
14
  import { loadSiteConfig } from "./config.js";
15
15
  import { createSiteApp } from "./app.js";
16
+ import { SiteRunner, runnerFromEnv } from "./runner.js";
16
17
  /**
17
18
  * Mode site (`mcp-organizer --server`) : un processus web permanent qui
18
19
  * détient la base, sert la GUI et son API, et écoute sur `HOST:PORT`.
@@ -35,6 +36,10 @@ export async function runServerMode(rootDir) {
35
36
  const transcriber = transcriberFromEnv();
36
37
  if (!transcriber.enabled)
37
38
  logger.warn(transcriber.disabledReason);
39
+ const runner = runnerFromEnv(process.env, { host: config.host, port: config.port, rootDir, dataDir });
40
+ if (!runner.status.enabled)
41
+ logger.warn(`Runner disabled: ${runner.status.disabledReason}`);
42
+ const siteRunner = runner.config ? new SiteRunner(store, runner.config, createLogger("runner")) : null;
38
43
  new NotificationMailer(store, { mailer, publicUrl: config.publicUrl, logError: (message) => logger.error(message) }).attach();
39
44
  let bootstrap;
40
45
  try {
@@ -62,6 +67,7 @@ export async function runServerMode(rootDir) {
62
67
  publicUrl: config.publicUrl,
63
68
  attachments: new AttachmentStorage(dataDir),
64
69
  transcriber,
70
+ runner: { status: runner.status, runNow: siteRunner ? (spaceId) => siteRunner.runNow(spaceId) : null },
65
71
  },
66
72
  });
67
73
  const server = await new Promise((resolve, reject) => {
@@ -71,12 +77,14 @@ export async function runServerMode(rootDir) {
71
77
  logger.info(`mcp-organizer v${APP_VERSION} listening on http://${config.host}:${config.port}, public URL ${config.publicUrl} (data dir: ${dataDir})`);
72
78
  if (config.trustProxy)
73
79
  logger.info("ORGANIZER_TRUST_PROXY=1: the client address is read from X-Forwarded-For.");
80
+ siteRunner?.start();
74
81
  let shuttingDown = false;
75
82
  const shutdown = (signal) => {
76
83
  if (shuttingDown)
77
84
  return;
78
85
  shuttingDown = true;
79
86
  logger.info(`${signal} received, shutting down`);
87
+ siteRunner?.stop();
80
88
  server.close(() => {
81
89
  store.close();
82
90
  process.exit(0);