@imenam/mcp-organizer 1.0.1 → 1.0.2
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/README.md +548 -530
- package/dist/shared/schemas/agent-api.schema.d.ts +1 -0
- package/dist/shared/schemas/agent-api.schema.js +5 -2
- package/dist/shared/schemas/agent-tools.schema.d.ts +94 -0
- package/dist/shared/schemas/agent-tools.schema.js +33 -0
- package/dist/src/agent-api/notifications.d.ts +16 -0
- package/dist/src/agent-api/notifications.js +91 -0
- package/dist/src/agent-api/router.js +2 -1
- package/dist/src/cli/watch.d.ts +4 -0
- package/dist/src/cli/watch.js +62 -0
- package/dist/src/core/requests.d.ts +2 -1
- package/dist/src/core/requests.js +3 -2
- package/dist/src/core/store.d.ts +12 -0
- package/dist/src/core/store.js +26 -0
- package/dist/src/core/watchers.d.ts +30 -0
- package/dist/src/core/watchers.js +50 -0
- package/dist/src/index.js +10 -0
- package/dist/src/mcp/client.d.ts +16 -1
- package/dist/src/mcp/client.js +29 -4
- package/dist/src/mcp/notifications.d.ts +13 -0
- package/dist/src/mcp/notifications.js +54 -0
- package/dist/src/mcp/start.d.ts +1 -1
- package/dist/src/mcp/start.js +21 -2
- package/dist/src/mcp/watch-handoff.d.ts +35 -0
- package/dist/src/mcp/watch-handoff.js +91 -0
- package/dist/src/server/app.js +23 -8
- package/gui/dist/assets/{index-lnvnkDdg.css → index-BVN0agCc.css} +1 -1
- package/gui/dist/assets/index-DI1_kr81.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/gui/dist/assets/index-DwkXx4Q2.js +0 -70
package/dist/src/mcp/client.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { type AgentEnvelope, type WaitRequestsData } from "../../shared/schemas/agent-tools.schema.js";
|
|
1
3
|
import type { ToolHttpRequest } from "./tools.js";
|
|
2
4
|
/**
|
|
3
5
|
* Client HTTP de l'API agent du site : seul point de sortie réseau du mode
|
|
4
6
|
* MCP. Chaque appel porte le jeton dans `Authorization: Bearer`.
|
|
5
7
|
*/
|
|
6
|
-
/** Délai maximal d'un appel au site. */
|
|
8
|
+
/** Délai maximal d'un appel au site, hors attente longue. */
|
|
7
9
|
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
8
10
|
/** Échec d'un appel : refus du site (avec son message) ou site injoignable. */
|
|
9
11
|
export declare class SiteError extends Error {
|
|
@@ -20,4 +22,17 @@ export declare class OrganizerSiteClient {
|
|
|
20
22
|
constructor(connection: SiteConnection);
|
|
21
23
|
/** Exécute la requête d'un outil et renvoie le texte rédigé pour l'agent. */
|
|
22
24
|
call(request: ToolHttpRequest): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Attente longue des demandes qui passent en attente (`GET requests/wait`) ;
|
|
27
|
+
* le délai de l'appel couvre la durée de l'attente.
|
|
28
|
+
*/
|
|
29
|
+
waitForRequests(query: {
|
|
30
|
+
timeout_seconds: number;
|
|
31
|
+
since?: string;
|
|
32
|
+
watcher?: boolean;
|
|
33
|
+
}): Promise<WaitRequestsData>;
|
|
34
|
+
/** Données structurées d'une réponse, validées par `schema`. */
|
|
35
|
+
parseData<S extends z.ZodType>(schema: S, data: unknown): z.infer<S>;
|
|
36
|
+
/** Exécute une requête de l'API agent et renvoie sa réponse : texte rédigé pour l'agent et données structurées. */
|
|
37
|
+
envelope(request: ToolHttpRequest, timeoutMs?: number): Promise<AgentEnvelope>;
|
|
23
38
|
}
|
package/dist/src/mcp/client.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { AgentEnvelopeSchema, AgentErrorSchema } from "../../shared/schemas/agent-tools.schema.js";
|
|
1
|
+
import { AgentEnvelopeSchema, AgentErrorSchema, WaitRequestsDataSchema } from "../../shared/schemas/agent-tools.schema.js";
|
|
2
2
|
/**
|
|
3
3
|
* Client HTTP de l'API agent du site : seul point de sortie réseau du mode
|
|
4
4
|
* MCP. Chaque appel porte le jeton dans `Authorization: Bearer`.
|
|
5
5
|
*/
|
|
6
|
-
/** Délai maximal d'un appel au site. */
|
|
6
|
+
/** Délai maximal d'un appel au site, hors attente longue. */
|
|
7
7
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
8
8
|
/** Échec d'un appel : refus du site (avec son message) ou site injoignable. */
|
|
9
9
|
export class SiteError extends Error {
|
|
@@ -33,6 +33,31 @@ export class OrganizerSiteClient {
|
|
|
33
33
|
}
|
|
34
34
|
/** Exécute la requête d'un outil et renvoie le texte rédigé pour l'agent. */
|
|
35
35
|
async call(request) {
|
|
36
|
+
return (await this.envelope(request)).text;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Attente longue des demandes qui passent en attente (`GET requests/wait`) ;
|
|
40
|
+
* le délai de l'appel couvre la durée de l'attente.
|
|
41
|
+
*/
|
|
42
|
+
async waitForRequests(query) {
|
|
43
|
+
const params = { timeout_seconds: String(query.timeout_seconds) };
|
|
44
|
+
if (query.since !== undefined)
|
|
45
|
+
params.since = query.since;
|
|
46
|
+
if (query.watcher)
|
|
47
|
+
params.watcher = "true";
|
|
48
|
+
const envelope = await this.envelope({ method: "GET", path: "requests/wait", query: params, body: undefined }, query.timeout_seconds * 1000 + REQUEST_TIMEOUT_MS);
|
|
49
|
+
return this.parseData(WaitRequestsDataSchema, envelope.data);
|
|
50
|
+
}
|
|
51
|
+
/** Données structurées d'une réponse, validées par `schema`. */
|
|
52
|
+
parseData(schema, data) {
|
|
53
|
+
const parsed = schema.safeParse(data);
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
throw new SiteError(`The organizer site at ${this.connection.url} sent an unexpected answer. Check that the site runs the same version of @imenam/mcp-organizer.`);
|
|
56
|
+
}
|
|
57
|
+
return parsed.data;
|
|
58
|
+
}
|
|
59
|
+
/** Exécute une requête de l'API agent et renvoie sa réponse : texte rédigé pour l'agent et données structurées. */
|
|
60
|
+
async envelope(request, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
36
61
|
const url = new URL(`${this.connection.url}/api/agent/${request.path}`);
|
|
37
62
|
for (const [name, value] of Object.entries(request.query))
|
|
38
63
|
url.searchParams.set(name, value);
|
|
@@ -48,7 +73,7 @@ export class OrganizerSiteClient {
|
|
|
48
73
|
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
49
74
|
// Une redirection n'est jamais suivie : le jeton ne part que vers ORGANIZER_URL.
|
|
50
75
|
redirect: "manual",
|
|
51
|
-
signal: AbortSignal.timeout(
|
|
76
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
52
77
|
});
|
|
53
78
|
}
|
|
54
79
|
catch (error) {
|
|
@@ -76,6 +101,6 @@ export class OrganizerSiteClient {
|
|
|
76
101
|
if (!envelope.success) {
|
|
77
102
|
throw new SiteError(`The organizer site at ${this.connection.url} sent an unexpected answer. Check ORGANIZER_URL and that the site runs the same version of @imenam/mcp-organizer.`);
|
|
78
103
|
}
|
|
79
|
-
return envelope.data
|
|
104
|
+
return envelope.data;
|
|
80
105
|
}
|
|
81
106
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OrganizerSiteClient, SiteConnection } from "./client.js";
|
|
2
|
+
import type { ToolDefinition } from "./tools.js";
|
|
3
|
+
/**
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
/** Durée maximale d'un `Monitor` côté hôte. */
|
|
9
|
+
export declare const MONITOR_TIMEOUT_MS = 1800000;
|
|
10
|
+
export declare const ENABLE_NOTIFICATIONS_TOOL: ToolDefinition;
|
|
11
|
+
/** Commande du veilleur du jeton `tokenId`. */
|
|
12
|
+
export declare function watchCommand(tokenId: string): string;
|
|
13
|
+
export declare function enableNotifications(client: OrganizerSiteClient, connection: SiteConnection): Promise<string>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NotificationsDataSchema } from "../../shared/schemas/agent-tools.schema.js";
|
|
2
|
+
import { writeHandoff } from "./watch-handoff.js";
|
|
3
|
+
/**
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
/** Durée maximale d'un `Monitor` côté hôte. */
|
|
9
|
+
export const MONITOR_TIMEOUT_MS = 1_800_000;
|
|
10
|
+
const PACKAGE_NAME = "@imenam/mcp-organizer";
|
|
11
|
+
export const ENABLE_NOTIFICATIONS_TOOL = {
|
|
12
|
+
name: "enable_notifications",
|
|
13
|
+
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 },
|
|
15
|
+
};
|
|
16
|
+
/** Commande du veilleur du jeton `tokenId`. */
|
|
17
|
+
export function watchCommand(tokenId) {
|
|
18
|
+
return `npx -y ${PACKAGE_NAME} --watch ${tokenId}`;
|
|
19
|
+
}
|
|
20
|
+
export async function enableNotifications(client, connection) {
|
|
21
|
+
const envelope = await client.envelope({ method: "GET", path: "notifications", query: {}, body: undefined });
|
|
22
|
+
const { token, space, pendingCount, watcher } = client.parseData(NotificationsDataSchema, envelope.data);
|
|
23
|
+
writeHandoff({ tokenId: token.id, url: connection.url, token: connection.token });
|
|
24
|
+
const command = watchCommand(token.id);
|
|
25
|
+
if (watcher.armed) {
|
|
26
|
+
return [
|
|
27
|
+
`Notifications are already live for token "${token.name}" on space "${space.name}" (watcher armed since ${watcher.since}).`,
|
|
28
|
+
"Nothing to do: keep working, new requests will interrupt you on their own.",
|
|
29
|
+
"Do not arm a second watcher — you would get every request twice.",
|
|
30
|
+
"Only exception: if the host just told you your watcher expired or stopped, re-arm it",
|
|
31
|
+
`with the same Monitor call (command \`${command}\`, timeout_ms ${MONITOR_TIMEOUT_MS}).`,
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
const lines = [
|
|
35
|
+
`Notifications enabled for token "${token.name}" on space "${space.name}".`,
|
|
36
|
+
"",
|
|
37
|
+
"Last step — call the Monitor tool now, with exactly these arguments:",
|
|
38
|
+
` command: ${command}`,
|
|
39
|
+
" description: New requests for the AI in the organizer",
|
|
40
|
+
` timeout_ms: ${MONITOR_TIMEOUT_MS}`,
|
|
41
|
+
"",
|
|
42
|
+
"Until that watcher is armed, nothing will reach you: no request will be announced.",
|
|
43
|
+
"Once it is, keep working normally — each request that becomes pending shows up on its own,",
|
|
44
|
+
"even while you are idle. One line per request; process it with claim_request.",
|
|
45
|
+
"",
|
|
46
|
+
"The host stops every monitor after 30 minutes at most. Each time it tells you this",
|
|
47
|
+
"watcher expired, call Monitor again right away with the same arguments — do not call",
|
|
48
|
+
"enable_notifications again. Skip that and notifications stop for good.",
|
|
49
|
+
];
|
|
50
|
+
if (pendingCount > 0) {
|
|
51
|
+
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
|
+
}
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
package/dist/src/mcp/start.d.ts
CHANGED
|
@@ -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.\
|
|
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.";
|
|
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`.
|
package/dist/src/mcp/start.js
CHANGED
|
@@ -6,6 +6,8 @@ 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";
|
|
10
|
+
import { removeOwnHandoffs } from "./watch-handoff.js";
|
|
9
11
|
/** Présentation du serveur transmise au client MCP : concepts et déroulé. */
|
|
10
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.
|
|
11
13
|
|
|
@@ -24,7 +26,9 @@ Work loop, when the user asks you to process the queue:
|
|
|
24
26
|
3. Read what you need with get_task and search_tasks.
|
|
25
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.
|
|
26
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.
|
|
27
|
-
|
|
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.
|
|
28
32
|
|
|
29
33
|
How to handle each kind of request:
|
|
30
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.
|
|
@@ -80,9 +84,19 @@ export async function runMcpMode() {
|
|
|
80
84
|
const logger = createLogger("mcp");
|
|
81
85
|
const client = new OrganizerSiteClient(connection);
|
|
82
86
|
const server = new Server({ name: "mcp-organizer", version: APP_VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
83
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: AGENT_TOOLS.map((tool) => tool.definition) }));
|
|
87
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...AGENT_TOOLS.map((tool) => tool.definition), ENABLE_NOTIFICATIONS_TOOL] }));
|
|
84
88
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
85
89
|
const { name, arguments: args } = request.params;
|
|
90
|
+
if (name === ENABLE_NOTIFICATIONS_TOOL.name) {
|
|
91
|
+
try {
|
|
92
|
+
return { content: [{ type: "text", text: await enableNotifications(client, connection) }] };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!(error instanceof SiteError))
|
|
96
|
+
logger.error(`Tool ${name} failed: ${error?.stack ?? error}`);
|
|
97
|
+
return errorResult(error?.message ?? String(error));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
86
100
|
const tool = findAgentTool(name);
|
|
87
101
|
if (!tool)
|
|
88
102
|
return errorResult(`Unknown tool "${name}". Call tools/list to get the available tools.`);
|
|
@@ -96,6 +110,11 @@ export async function runMcpMode() {
|
|
|
96
110
|
return errorResult(error?.message ?? String(error));
|
|
97
111
|
}
|
|
98
112
|
});
|
|
113
|
+
// Le relais du veilleur porte le jeton : il ne survit pas au mode MCP qui l'a écrit.
|
|
114
|
+
process.on("exit", removeOwnHandoffs);
|
|
115
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
116
|
+
process.on(signal, () => process.exit(0));
|
|
117
|
+
}
|
|
99
118
|
await server.connect(new StdioServerTransport());
|
|
100
119
|
logger.info(`MCP server running on stdio (version ${APP_VERSION}, site: ${connection.url})`);
|
|
101
120
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relais entre la session MCP et son veilleur de notifications.
|
|
3
|
+
*
|
|
4
|
+
* Le veilleur est lancé par l'agent via l'outil `Monitor` de l'hôte : c'est un
|
|
5
|
+
* processus du shell de la session, pas un enfant du mode MCP. Il n'hérite donc
|
|
6
|
+
* pas du bloc `env` du `.mcp.json` et n'a ni URL du site ni jeton. Les passer sur
|
|
7
|
+
* la ligne de commande les afficherait dans la conversation : ils transitent par
|
|
8
|
+
* un fichier en 0600, que seul l'identifiant du jeton (pas sa valeur) permet de
|
|
9
|
+
* retrouver.
|
|
10
|
+
*
|
|
11
|
+
* Le fichier est écrit par `enable_notifications`, jamais au démarrage, et retiré
|
|
12
|
+
* à l'arrêt du mode MCP qui l'a écrit en dernier.
|
|
13
|
+
*/
|
|
14
|
+
export interface Handoff {
|
|
15
|
+
/** Identifiant du jeton d'agent. */
|
|
16
|
+
tokenId: string;
|
|
17
|
+
url: string;
|
|
18
|
+
/** Valeur du jeton. */
|
|
19
|
+
token: string;
|
|
20
|
+
/** Processus du mode MCP qui a écrit le relais. */
|
|
21
|
+
pid: number;
|
|
22
|
+
/** Horodatage d'écriture, qui sert à purger les relais abandonnés. */
|
|
23
|
+
writtenAt: string;
|
|
24
|
+
}
|
|
25
|
+
/** Répertoire des relais : `<ORGANIZER_STATE_DIR>/watch`, ou `~/.mcp-organizer/watch`. */
|
|
26
|
+
export declare function handoffDir(): string;
|
|
27
|
+
/** Fichier du relais d'un jeton, nommé d'après le hachage de son identifiant. */
|
|
28
|
+
export declare function handoffPath(tokenId: string): string;
|
|
29
|
+
export declare function writeHandoff(handoff: Omit<Handoff, "pid" | "writtenAt">): string;
|
|
30
|
+
/** Relais du jeton `tokenId`, ou null s'il n'existe pas ou ne concerne pas ce jeton. */
|
|
31
|
+
export declare function readHandoff(tokenId: string): Handoff | null;
|
|
32
|
+
/** Supprime le relais du jeton s'il a été écrit en dernier par ce processus. */
|
|
33
|
+
export declare function removeHandoff(tokenId: string): void;
|
|
34
|
+
/** Supprime les relais écrits en dernier par ce processus. Synchrone : appelable sur `exit`. */
|
|
35
|
+
export declare function removeOwnHandoffs(): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/** Âge au-delà duquel un relais resté en place (session tuée, machine redémarrée) est supprimé. */
|
|
6
|
+
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
/** Répertoire des relais : `<ORGANIZER_STATE_DIR>/watch`, ou `~/.mcp-organizer/watch`. */
|
|
8
|
+
export function handoffDir() {
|
|
9
|
+
const override = process.env.ORGANIZER_STATE_DIR?.trim();
|
|
10
|
+
return override ? path.join(override, "watch") : path.join(os.homedir(), ".mcp-organizer", "watch");
|
|
11
|
+
}
|
|
12
|
+
/** Fichier du relais d'un jeton, nommé d'après le hachage de son identifiant. */
|
|
13
|
+
export function handoffPath(tokenId) {
|
|
14
|
+
const digest = crypto.createHash("sha256").update(tokenId).digest("hex").slice(0, 32);
|
|
15
|
+
return path.join(handoffDir(), `${digest}.json`);
|
|
16
|
+
}
|
|
17
|
+
/** Jetons dont ce processus a écrit le relais. */
|
|
18
|
+
const written = new Set();
|
|
19
|
+
export function writeHandoff(handoff) {
|
|
20
|
+
const target = handoffPath(handoff.tokenId);
|
|
21
|
+
written.add(handoff.tokenId);
|
|
22
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
23
|
+
const payload = { ...handoff, pid: process.pid, writtenAt: new Date().toISOString() };
|
|
24
|
+
fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
25
|
+
try {
|
|
26
|
+
fs.chmodSync(target, 0o600);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Windows ignore les permissions POSIX ; le fichier reste dans le profil de l'utilisateur.
|
|
30
|
+
}
|
|
31
|
+
pruneStaleHandoffs();
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
/** Relais du jeton `tokenId`, ou null s'il n'existe pas ou ne concerne pas ce jeton. */
|
|
35
|
+
export function readHandoff(tokenId) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(handoffPath(tokenId), "utf-8");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (parsed.tokenId !== tokenId || typeof parsed.url !== "string" || typeof parsed.token !== "string")
|
|
46
|
+
return null;
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Supprime le relais du jeton s'il a été écrit en dernier par ce processus. */
|
|
54
|
+
export function removeHandoff(tokenId) {
|
|
55
|
+
written.delete(tokenId);
|
|
56
|
+
if (readHandoff(tokenId)?.pid !== process.pid)
|
|
57
|
+
return;
|
|
58
|
+
try {
|
|
59
|
+
fs.rmSync(handoffPath(tokenId), { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Un relais qui n'a pas pu être supprimé est purgé plus tard, par ancienneté.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Supprime les relais écrits en dernier par ce processus. Synchrone : appelable sur `exit`. */
|
|
66
|
+
export function removeOwnHandoffs() {
|
|
67
|
+
for (const tokenId of [...written])
|
|
68
|
+
removeHandoff(tokenId);
|
|
69
|
+
}
|
|
70
|
+
function pruneStaleHandoffs() {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = fs.readdirSync(handoffDir());
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (!entry.endsWith(".json"))
|
|
81
|
+
continue;
|
|
82
|
+
const file = path.join(handoffDir(), entry);
|
|
83
|
+
try {
|
|
84
|
+
if (now - fs.statSync(file).mtimeMs > STALE_AFTER_MS)
|
|
85
|
+
fs.rmSync(file, { force: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Un fichier illisible ou déjà supprimé n'empêche pas l'écriture du relais.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/src/server/app.js
CHANGED
|
@@ -11,6 +11,7 @@ import { MAX_ATTACHMENT_BYTES } from "../../shared/schemas/tasks-api.schema.js";
|
|
|
11
11
|
import { MAX_TRANSCRIPTION_BYTES } from "../../shared/schemas/transcription-api.schema.js";
|
|
12
12
|
import { errorKindOf, errorStatusOf, handleApiRequest } from "../gui-api.js";
|
|
13
13
|
import { handleAgentRequest } from "../agent-api/router.js";
|
|
14
|
+
import { waitForPendingRequests } from "../agent-api/notifications.js";
|
|
14
15
|
import { LoginLimiter } from "./login-limiter.js";
|
|
15
16
|
import { MAX_BODY_BYTES } from "./limits.js";
|
|
16
17
|
/**
|
|
@@ -207,16 +208,30 @@ export function createSiteApp(deps) {
|
|
|
207
208
|
return c.json(response);
|
|
208
209
|
}
|
|
209
210
|
catch (err) {
|
|
210
|
-
|
|
211
|
-
if (kind === "internal") {
|
|
212
|
-
const reference = logInternalError(`Agent request ${c.req.method} ${c.req.path}`, err);
|
|
213
|
-
return c.json({ error: `Internal error of the organizer site (reference ${reference}): retry later; if it persists, give this reference to the administrator of the site, who finds the details in its log.` }, 500);
|
|
214
|
-
}
|
|
215
|
-
if (kind === "unauthorized")
|
|
216
|
-
c.header("WWW-Authenticate", 'Bearer realm="mcp-organizer"');
|
|
217
|
-
return c.json({ error: err?.message ?? String(err) }, HTTP_STATUS_BY_ERROR_KIND[kind]);
|
|
211
|
+
return agentError(c, err);
|
|
218
212
|
}
|
|
219
213
|
};
|
|
214
|
+
/** Réponse d'erreur de l'API des agents, avec le code HTTP de sa nature. */
|
|
215
|
+
function agentError(c, err) {
|
|
216
|
+
const kind = errorKindOf(err);
|
|
217
|
+
if (kind === "internal") {
|
|
218
|
+
const reference = logInternalError(`Agent request ${c.req.method} ${c.req.path}`, err);
|
|
219
|
+
return c.json({ error: `Internal error of the organizer site (reference ${reference}): retry later; if it persists, give this reference to the administrator of the site, who finds the details in its log.` }, 500);
|
|
220
|
+
}
|
|
221
|
+
if (kind === "unauthorized")
|
|
222
|
+
c.header("WWW-Authenticate", 'Bearer realm="mcp-organizer"');
|
|
223
|
+
return c.json({ error: err?.message ?? String(err) }, HTTP_STATUS_BY_ERROR_KIND[kind]);
|
|
224
|
+
}
|
|
225
|
+
// Attente longue des demandes : seule route asynchrone de l'API des agents.
|
|
226
|
+
app.get(`${AGENT_API_PREFIX}/requests/wait`, async (c) => {
|
|
227
|
+
try {
|
|
228
|
+
const response = await waitForPendingRequests({ store: api.store, now: api.now }, { method: "GET", path: "/requests/wait", query: c.req.query(), bearer: bearerToken(c) }, c.req.raw.signal);
|
|
229
|
+
return c.json(response);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
return agentError(c, err);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
220
235
|
app.all(AGENT_API_PREFIX, agentApi);
|
|
221
236
|
app.all(`${AGENT_API_PREFIX}/*`, agentApi);
|
|
222
237
|
// Toute route `/api/*` est traitée par le routeur de l'API, avec le jeton du
|