senq-mcp 1.0.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.
@@ -0,0 +1,3 @@
1
+ export declare function apiGet<T>(path: string, params?: Record<string, string>): Promise<T>;
2
+ export declare function apiPost<T>(path: string, body: unknown): Promise<T>;
3
+ export declare function apiPatch<T>(path: string, body: unknown): Promise<T>;
package/dist/client.js ADDED
@@ -0,0 +1,63 @@
1
+ const BASE_URL = (process.env.SENQ_BASE_URL ?? "https://senq.serenity.agency").replace(/\/$/, "");
2
+ function getApiKey() {
3
+ const key = process.env.SENQ_API_KEY ?? "";
4
+ if (!key) {
5
+ console.error("[senq-mcp] SENQ_API_KEY is not set. Run: npx senq-mcp setup");
6
+ process.exit(1);
7
+ }
8
+ return key;
9
+ }
10
+ function getViewer() {
11
+ return process.env.SENQ_VIEWER ?? "";
12
+ }
13
+ export async function apiGet(path, params) {
14
+ const API_KEY = getApiKey();
15
+ const VIEWER = getViewer();
16
+ const url = new URL(BASE_URL + path);
17
+ if (VIEWER)
18
+ url.searchParams.set("viewer", VIEWER);
19
+ if (params)
20
+ Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
21
+ const res = await fetch(url.toString(), {
22
+ headers: { Authorization: `Bearer ${API_KEY}` },
23
+ });
24
+ if (!res.ok)
25
+ throw new Error(`API ${res.status}: ${await res.text()}`);
26
+ return res.json();
27
+ }
28
+ export async function apiPost(path, body) {
29
+ const API_KEY = getApiKey();
30
+ const VIEWER = getViewer();
31
+ const url = new URL(BASE_URL + path);
32
+ if (VIEWER)
33
+ url.searchParams.set("viewer", VIEWER);
34
+ const res = await fetch(url.toString(), {
35
+ method: "POST",
36
+ headers: {
37
+ Authorization: `Bearer ${API_KEY}`,
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify(body),
41
+ });
42
+ if (!res.ok)
43
+ throw new Error(`API ${res.status}: ${await res.text()}`);
44
+ return res.json();
45
+ }
46
+ export async function apiPatch(path, body) {
47
+ const API_KEY = getApiKey();
48
+ const VIEWER = getViewer();
49
+ const url = new URL(BASE_URL + path);
50
+ if (VIEWER)
51
+ url.searchParams.set("viewer", VIEWER);
52
+ const res = await fetch(url.toString(), {
53
+ method: "PATCH",
54
+ headers: {
55
+ Authorization: `Bearer ${API_KEY}`,
56
+ "Content-Type": "application/json",
57
+ },
58
+ body: JSON.stringify(body),
59
+ });
60
+ if (!res.ok)
61
+ throw new Error(`API ${res.status}: ${await res.text()}`);
62
+ return res.json();
63
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { apiGet, apiPost, apiPatch } from "./client.js";
6
+ import { storePending, consumePending } from "./pending-changes.js";
7
+ if (process.argv[2] === "setup") {
8
+ const { runSetup } = await import("./setup.js");
9
+ await runSetup();
10
+ process.exit(0);
11
+ }
12
+ const server = new Server({ name: "senq-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
13
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
14
+ tools: [
15
+ {
16
+ name: "list_tasks",
17
+ description: "Список задач пользователя. Без параметров — задачи на сегодня.",
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ list: { type: "string", enum: ["today", "personal", "plans", "someday", "completed"], description: "Тип списка" },
22
+ projectId: { type: "string", description: "ID проекта" },
23
+ status: { type: "string", enum: ["open", "completed"] },
24
+ limit: { type: "number", description: "Макс. количество (по умолчанию 20)" },
25
+ },
26
+ },
27
+ },
28
+ {
29
+ name: "get_task",
30
+ description: "Получить задачу по ID с деталями, комментариями и подзадачами.",
31
+ inputSchema: {
32
+ type: "object",
33
+ required: ["id"],
34
+ properties: {
35
+ id: { type: "string", description: "ID задачи" },
36
+ },
37
+ },
38
+ },
39
+ {
40
+ name: "search_tasks",
41
+ description: "Поиск задач по тексту заголовка.",
42
+ inputSchema: {
43
+ type: "object",
44
+ required: ["query"],
45
+ properties: {
46
+ query: { type: "string", description: "Поисковый запрос" },
47
+ limit: { type: "number" },
48
+ },
49
+ },
50
+ },
51
+ {
52
+ name: "propose_task_change",
53
+ description: "Предложить создание или изменение задачи. Возвращает preview и токен для подтверждения — не пишет сразу.",
54
+ inputSchema: {
55
+ type: "object",
56
+ required: ["action"],
57
+ properties: {
58
+ action: { type: "string", enum: ["create", "update"], description: "Действие" },
59
+ taskId: { type: "string", description: "ID задачи (для action=update)" },
60
+ title: { type: "string" },
61
+ descriptionHtml: { type: "string" },
62
+ dueDate: { type: "string", description: "YYYY-MM-DD" },
63
+ status: { type: "string", enum: ["open", "completed"] },
64
+ projectId: { type: "string" },
65
+ todayDate: { type: "string", description: "YYYY-MM-DD — поставить в «Сегодня»" },
66
+ priority: { type: "number", description: "0–3 (0 = нет, 3 = высокий)" },
67
+ },
68
+ },
69
+ },
70
+ {
71
+ name: "confirm_task_change",
72
+ description: "Выполнить изменение задачи, подтверждённое пользователем. Передай токен из propose_task_change.",
73
+ inputSchema: {
74
+ type: "object",
75
+ required: ["token"],
76
+ properties: {
77
+ token: { type: "string", description: "Токен из propose_task_change" },
78
+ },
79
+ },
80
+ },
81
+ {
82
+ name: "list_projects",
83
+ description: "Список проектов доступных пользователю.",
84
+ inputSchema: { type: "object", properties: {} },
85
+ },
86
+ {
87
+ name: "search_knowledge",
88
+ description: "Поиск по базе знаний.",
89
+ inputSchema: {
90
+ type: "object",
91
+ required: ["query"],
92
+ properties: {
93
+ query: { type: "string" },
94
+ limit: { type: "number" },
95
+ },
96
+ },
97
+ },
98
+ {
99
+ name: "get_analytics",
100
+ description: "Аналитика по задачам пользователя. Типы: summary (итоги), stuck (застрявшие), deadline_risk (дедлайны).",
101
+ inputSchema: {
102
+ type: "object",
103
+ required: ["type"],
104
+ properties: {
105
+ type: { type: "string", enum: ["summary", "stuck", "deadline_risk"] },
106
+ },
107
+ },
108
+ },
109
+ ],
110
+ }));
111
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
112
+ const { name, arguments: args } = req.params;
113
+ try {
114
+ if (name === "list_tasks") {
115
+ const params = {};
116
+ if (args?.list)
117
+ params.list = String(args.list);
118
+ else
119
+ params.list = "today";
120
+ if (args?.projectId)
121
+ params.projectId = String(args.projectId);
122
+ if (args?.status)
123
+ params.status = String(args.status);
124
+ if (args?.limit)
125
+ params.limit = String(args.limit);
126
+ const data = await apiGet("/api/tasks", params);
127
+ const tasks = data.tasks ?? (Array.isArray(data) ? data : []);
128
+ return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
129
+ }
130
+ if (name === "get_task") {
131
+ const id = String(args?.id ?? "");
132
+ const data = await apiGet(`/api/tasks/${id}`);
133
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
134
+ }
135
+ if (name === "search_tasks") {
136
+ const query = String(args?.query ?? "");
137
+ const limit = args?.limit ? String(args.limit) : "10";
138
+ const data = await apiGet("/api/tasks/search", { q: query, limit });
139
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
140
+ }
141
+ if (name === "propose_task_change") {
142
+ const action = String(args?.action ?? "create");
143
+ const payload = {};
144
+ const previewParts = [];
145
+ if (action === "create") {
146
+ if (!args?.title)
147
+ return { content: [{ type: "text", text: "Ошибка: title обязателен для создания задачи" }] };
148
+ payload.title = args.title;
149
+ previewParts.push(`Создать задачу: «${args.title}»`);
150
+ if (args?.dueDate) {
151
+ payload.dueDate = args.dueDate;
152
+ previewParts.push(`Срок: ${args.dueDate}`);
153
+ }
154
+ if (args?.projectId) {
155
+ payload.projectId = args.projectId;
156
+ previewParts.push(`Проект: ${args.projectId}`);
157
+ }
158
+ if (args?.todayDate) {
159
+ payload.todayDate = args.todayDate;
160
+ previewParts.push(`В «Сегодня»: ${args.todayDate}`);
161
+ }
162
+ if (args?.priority) {
163
+ payload.priority = args.priority;
164
+ previewParts.push(`Приоритет: ${args.priority}`);
165
+ }
166
+ if (args?.descriptionHtml)
167
+ payload.descriptionHtml = args.descriptionHtml;
168
+ }
169
+ else {
170
+ const taskId = String(args?.taskId ?? "");
171
+ if (!taskId)
172
+ return { content: [{ type: "text", text: "Ошибка: taskId обязателен для обновления" }] };
173
+ payload.taskId = taskId;
174
+ previewParts.push(`Изменить задачу ${taskId}`);
175
+ if (args?.title !== undefined) {
176
+ payload.title = args.title;
177
+ previewParts.push(`Новый заголовок: «${args.title}»`);
178
+ }
179
+ if (args?.status) {
180
+ payload.status = args.status;
181
+ previewParts.push(`Статус: ${args.status}`);
182
+ }
183
+ if (args?.dueDate !== undefined) {
184
+ payload.dueDate = args.dueDate;
185
+ previewParts.push(`Срок: ${args.dueDate ?? "убрать"}`);
186
+ }
187
+ if (args?.todayDate !== undefined) {
188
+ payload.todayDate = args.todayDate;
189
+ }
190
+ if (args?.priority !== undefined) {
191
+ payload.priority = args.priority;
192
+ }
193
+ }
194
+ const token = storePending({ type: action === "create" ? "create_task" : "update_task", payload, preview: previewParts.join(", ") });
195
+ return {
196
+ content: [{
197
+ type: "text",
198
+ text: `📋 Preview: ${previewParts.join(" | ")}\n\nЧтобы применить, вызови confirm_task_change с токеном: ${token}\nТокен действителен 5 минут.`,
199
+ }],
200
+ };
201
+ }
202
+ if (name === "confirm_task_change") {
203
+ const token = String(args?.token ?? "");
204
+ const change = consumePending(token);
205
+ if (!change)
206
+ return { content: [{ type: "text", text: "Токен не найден или истёк. Попроси новый preview." }] };
207
+ let result;
208
+ if (change.type === "create_task") {
209
+ result = await apiPost("/api/tasks", change.payload);
210
+ }
211
+ else {
212
+ const { taskId, ...rest } = change.payload;
213
+ result = await apiPatch(`/api/tasks/${taskId}`, rest);
214
+ }
215
+ return { content: [{ type: "text", text: `✅ Готово: ${change.preview}\n\n${JSON.stringify(result, null, 2)}` }] };
216
+ }
217
+ if (name === "list_projects") {
218
+ const data = await apiGet("/api/registry");
219
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
220
+ }
221
+ if (name === "search_knowledge") {
222
+ const query = String(args?.query ?? "");
223
+ const limit = args?.limit ? String(args.limit) : "8";
224
+ const data = await apiGet("/api/knowledge/search", { q: query, limit });
225
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
226
+ }
227
+ if (name === "get_analytics") {
228
+ const type = String(args?.type ?? "summary");
229
+ const pathMap = {
230
+ summary: "/api/analytics/summary",
231
+ stuck: "/api/analytics/stuck-tasks",
232
+ deadline_risk: "/api/analytics/deadline-risk",
233
+ };
234
+ const apiPath = pathMap[type];
235
+ if (!apiPath)
236
+ return { content: [{ type: "text", text: `Неизвестный тип: ${type}` }] };
237
+ const data = await apiGet(apiPath);
238
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
239
+ }
240
+ return { content: [{ type: "text", text: `Неизвестный инструмент: ${name}` }] };
241
+ }
242
+ catch (err) {
243
+ const msg = err instanceof Error ? err.message : String(err);
244
+ return { content: [{ type: "text", text: `Ошибка: ${msg}` }], isError: true };
245
+ }
246
+ });
247
+ const transport = new StdioServerTransport();
248
+ await server.connect(transport);
@@ -0,0 +1,9 @@
1
+ type PendingChange = {
2
+ type: "create_task" | "update_task";
3
+ payload: Record<string, unknown>;
4
+ preview: string;
5
+ expiresAt: number;
6
+ };
7
+ export declare function storePending(change: Omit<PendingChange, "expiresAt">): string;
8
+ export declare function consumePending(token: string): PendingChange | null;
9
+ export {};
@@ -0,0 +1,24 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const store = new Map();
3
+ export function storePending(change) {
4
+ const token = randomUUID();
5
+ store.set(token, { ...change, expiresAt: Date.now() + 5 * 60 * 1000 });
6
+ return token;
7
+ }
8
+ export function consumePending(token) {
9
+ const change = store.get(token);
10
+ if (!change)
11
+ return null;
12
+ if (change.expiresAt < Date.now()) {
13
+ store.delete(token);
14
+ return null;
15
+ }
16
+ store.delete(token);
17
+ return change;
18
+ }
19
+ setInterval(() => {
20
+ const now = Date.now();
21
+ for (const [k, v] of store)
22
+ if (v.expiresAt < now)
23
+ store.delete(k);
24
+ }, 60_000);
@@ -0,0 +1 @@
1
+ export declare function runSetup(): Promise<void>;
package/dist/setup.js ADDED
@@ -0,0 +1,124 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import * as readline from "readline";
5
+ function getConfigPath(client) {
6
+ const home = os.homedir();
7
+ switch (client) {
8
+ case "claude":
9
+ if (process.platform === "win32")
10
+ return path.join(process.env.APPDATA ?? home, "Claude", "claude_desktop_config.json");
11
+ if (process.platform === "linux")
12
+ return path.join(home, ".config", "Claude", "claude_desktop_config.json");
13
+ return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
14
+ case "cursor":
15
+ return path.join(home, ".cursor", "mcp.json");
16
+ case "claudecode":
17
+ return path.join(home, ".claude.json");
18
+ default:
19
+ return "";
20
+ }
21
+ }
22
+ const CLIENTS = [
23
+ { id: "claude", label: "Claude Desktop", configPath: getConfigPath("claude") },
24
+ { id: "cursor", label: "Cursor", configPath: getConfigPath("cursor") },
25
+ { id: "claudecode", label: "Claude Code", configPath: getConfigPath("claudecode") },
26
+ ];
27
+ function readHidden(prompt) {
28
+ return new Promise((resolve) => {
29
+ process.stdout.write(prompt);
30
+ if (!process.stdin.isTTY) {
31
+ const rl = readline.createInterface({ input: process.stdin });
32
+ rl.once("line", (line) => { rl.close(); resolve(line.trim()); });
33
+ return;
34
+ }
35
+ const chars = [];
36
+ const onData = (buf) => {
37
+ const char = buf.toString("utf8");
38
+ if (char === "\n" || char === "\r" || char === "") {
39
+ process.stdin.setRawMode(false);
40
+ process.stdin.pause();
41
+ process.stdin.removeListener("data", onData);
42
+ process.stdout.write("\n");
43
+ resolve(chars.join(""));
44
+ }
45
+ else if (char === "") {
46
+ process.stdout.write("\n");
47
+ process.exit(0);
48
+ }
49
+ else if (char === "" || char === "\b") {
50
+ if (chars.length > 0) {
51
+ chars.pop();
52
+ process.stdout.write("\b \b");
53
+ }
54
+ }
55
+ else {
56
+ chars.push(char);
57
+ process.stdout.write("*");
58
+ }
59
+ };
60
+ process.stdin.setRawMode(true);
61
+ process.stdin.resume();
62
+ process.stdin.on("data", onData);
63
+ });
64
+ }
65
+ function ask(question, defaultVal = "") {
66
+ return new Promise((resolve) => {
67
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
68
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim() || defaultVal); });
69
+ });
70
+ }
71
+ function mergeConfig(configPath, apiKey) {
72
+ let config = {};
73
+ if (fs.existsSync(configPath)) {
74
+ try {
75
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
76
+ }
77
+ catch { /* corrupt — start fresh */ }
78
+ }
79
+ const mcpServers = config["mcpServers"] ?? {};
80
+ mcpServers["senq"] = { command: "npx", args: ["senq-mcp"], env: { SENQ_API_KEY: apiKey } };
81
+ config["mcpServers"] = mcpServers;
82
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
83
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
84
+ }
85
+ export async function runSetup() {
86
+ console.log("\n🤖 Настройка Senq MCP\n");
87
+ const apiKey = await readHidden("Вставь API-ключ из Senq: ");
88
+ if (!apiKey.startsWith("sk-senq-")) {
89
+ console.error("\n❌ Ключ должен начинаться с sk-senq-.\n Получи ключ в настройках Senq → API-ключи.\n");
90
+ process.exit(1);
91
+ }
92
+ console.log("\nДля какого клиента настроить?\n");
93
+ CLIENTS.forEach((c, i) => {
94
+ const exists = fs.existsSync(c.configPath);
95
+ console.log(` ${i + 1}) ${c.label}${exists ? " (конфиг найден)" : ""}`);
96
+ });
97
+ console.log(` ${CLIENTS.length + 1}) Все три\n`);
98
+ const choice = await ask("Введи номер [1]: ", "1");
99
+ const num = parseInt(choice, 10);
100
+ let selected;
101
+ if (num === CLIENTS.length + 1) {
102
+ selected = CLIENTS;
103
+ }
104
+ else if (num >= 1 && num <= CLIENTS.length) {
105
+ selected = [CLIENTS[num - 1]];
106
+ }
107
+ else {
108
+ selected = [CLIENTS[0]];
109
+ }
110
+ console.log("");
111
+ for (const client of selected) {
112
+ try {
113
+ mergeConfig(client.configPath, apiKey);
114
+ console.log(` ✅ ${client.label}: ${client.configPath}`);
115
+ }
116
+ catch (err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ console.error(` ❌ ${client.label}: ${msg}`);
119
+ }
120
+ }
121
+ const names = selected.map((c) => c.label).join(" и ");
122
+ console.log(`\n🎉 Готово! Перезапусти ${names}.\n`);
123
+ console.log(" Затем спроси: «Покажи мои задачи на сегодня»\n");
124
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "senq-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Senq — connect your AI to tasks and knowledge",
5
+ "type": "module",
6
+ "bin": {
7
+ "senq-mcp": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/index.ts"
12
+ },
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.12.1"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.0.0",
18
+ "tsx": "^4.19.0",
19
+ "typescript": "^5.8.0"
20
+ }
21
+ }
package/src/client.ts ADDED
@@ -0,0 +1,61 @@
1
+ const BASE_URL = (process.env.SENQ_BASE_URL ?? "https://senq.serenity.agency").replace(/\/$/, "");
2
+
3
+ function getApiKey(): string {
4
+ const key = process.env.SENQ_API_KEY ?? "";
5
+ if (!key) {
6
+ console.error("[senq-mcp] SENQ_API_KEY is not set. Run: npx senq-mcp setup");
7
+ process.exit(1);
8
+ }
9
+ return key;
10
+ }
11
+
12
+ function getViewer(): string {
13
+ return process.env.SENQ_VIEWER ?? "";
14
+ }
15
+
16
+ export async function apiGet<T>(path: string, params?: Record<string, string>): Promise<T> {
17
+ const API_KEY = getApiKey();
18
+ const VIEWER = getViewer();
19
+ const url = new URL(BASE_URL + path);
20
+ if (VIEWER) url.searchParams.set("viewer", VIEWER);
21
+ if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
22
+ const res = await fetch(url.toString(), {
23
+ headers: { Authorization: `Bearer ${API_KEY}` },
24
+ });
25
+ if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
26
+ return res.json() as Promise<T>;
27
+ }
28
+
29
+ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
30
+ const API_KEY = getApiKey();
31
+ const VIEWER = getViewer();
32
+ const url = new URL(BASE_URL + path);
33
+ if (VIEWER) url.searchParams.set("viewer", VIEWER);
34
+ const res = await fetch(url.toString(), {
35
+ method: "POST",
36
+ headers: {
37
+ Authorization: `Bearer ${API_KEY}`,
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify(body),
41
+ });
42
+ if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
43
+ return res.json() as Promise<T>;
44
+ }
45
+
46
+ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
47
+ const API_KEY = getApiKey();
48
+ const VIEWER = getViewer();
49
+ const url = new URL(BASE_URL + path);
50
+ if (VIEWER) url.searchParams.set("viewer", VIEWER);
51
+ const res = await fetch(url.toString(), {
52
+ method: "PATCH",
53
+ headers: {
54
+ Authorization: `Bearer ${API_KEY}`,
55
+ "Content-Type": "application/json",
56
+ },
57
+ body: JSON.stringify(body),
58
+ });
59
+ if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
60
+ return res.json() as Promise<T>;
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import { apiGet, apiPost, apiPatch } from "./client.js";
9
+ import { storePending, consumePending } from "./pending-changes.js";
10
+
11
+ if (process.argv[2] === "setup") {
12
+ const { runSetup } = await import("./setup.js");
13
+ await runSetup();
14
+ process.exit(0);
15
+ }
16
+
17
+ const server = new Server(
18
+ { name: "senq-mcp", version: "1.0.0" },
19
+ { capabilities: { tools: {} } },
20
+ );
21
+
22
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
23
+ tools: [
24
+ {
25
+ name: "list_tasks",
26
+ description: "Список задач пользователя. Без параметров — задачи на сегодня.",
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ list: { type: "string", enum: ["today", "personal", "plans", "someday", "completed"], description: "Тип списка" },
31
+ projectId: { type: "string", description: "ID проекта" },
32
+ status: { type: "string", enum: ["open", "completed"] },
33
+ limit: { type: "number", description: "Макс. количество (по умолчанию 20)" },
34
+ },
35
+ },
36
+ },
37
+ {
38
+ name: "get_task",
39
+ description: "Получить задачу по ID с деталями, комментариями и подзадачами.",
40
+ inputSchema: {
41
+ type: "object",
42
+ required: ["id"],
43
+ properties: {
44
+ id: { type: "string", description: "ID задачи" },
45
+ },
46
+ },
47
+ },
48
+ {
49
+ name: "search_tasks",
50
+ description: "Поиск задач по тексту заголовка.",
51
+ inputSchema: {
52
+ type: "object",
53
+ required: ["query"],
54
+ properties: {
55
+ query: { type: "string", description: "Поисковый запрос" },
56
+ limit: { type: "number" },
57
+ },
58
+ },
59
+ },
60
+ {
61
+ name: "propose_task_change",
62
+ description: "Предложить создание или изменение задачи. Возвращает preview и токен для подтверждения — не пишет сразу.",
63
+ inputSchema: {
64
+ type: "object",
65
+ required: ["action"],
66
+ properties: {
67
+ action: { type: "string", enum: ["create", "update"], description: "Действие" },
68
+ taskId: { type: "string", description: "ID задачи (для action=update)" },
69
+ title: { type: "string" },
70
+ descriptionHtml: { type: "string" },
71
+ dueDate: { type: "string", description: "YYYY-MM-DD" },
72
+ status: { type: "string", enum: ["open", "completed"] },
73
+ projectId: { type: "string" },
74
+ todayDate: { type: "string", description: "YYYY-MM-DD — поставить в «Сегодня»" },
75
+ priority: { type: "number", description: "0–3 (0 = нет, 3 = высокий)" },
76
+ },
77
+ },
78
+ },
79
+ {
80
+ name: "confirm_task_change",
81
+ description: "Выполнить изменение задачи, подтверждённое пользователем. Передай токен из propose_task_change.",
82
+ inputSchema: {
83
+ type: "object",
84
+ required: ["token"],
85
+ properties: {
86
+ token: { type: "string", description: "Токен из propose_task_change" },
87
+ },
88
+ },
89
+ },
90
+ {
91
+ name: "list_projects",
92
+ description: "Список проектов доступных пользователю.",
93
+ inputSchema: { type: "object", properties: {} },
94
+ },
95
+ {
96
+ name: "search_knowledge",
97
+ description: "Поиск по базе знаний.",
98
+ inputSchema: {
99
+ type: "object",
100
+ required: ["query"],
101
+ properties: {
102
+ query: { type: "string" },
103
+ limit: { type: "number" },
104
+ },
105
+ },
106
+ },
107
+ {
108
+ name: "get_analytics",
109
+ description: "Аналитика по задачам пользователя. Типы: summary (итоги), stuck (застрявшие), deadline_risk (дедлайны).",
110
+ inputSchema: {
111
+ type: "object",
112
+ required: ["type"],
113
+ properties: {
114
+ type: { type: "string", enum: ["summary", "stuck", "deadline_risk"] },
115
+ },
116
+ },
117
+ },
118
+ ],
119
+ }));
120
+
121
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
122
+ const { name, arguments: args } = req.params;
123
+
124
+ try {
125
+ if (name === "list_tasks") {
126
+ const params: Record<string, string> = {};
127
+ if (args?.list) params.list = String(args.list);
128
+ else params.list = "today";
129
+ if (args?.projectId) params.projectId = String(args.projectId);
130
+ if (args?.status) params.status = String(args.status);
131
+ if (args?.limit) params.limit = String(args.limit);
132
+ const data = await apiGet<{ tasks?: unknown[] }>("/api/tasks", params);
133
+ const tasks = data.tasks ?? (Array.isArray(data) ? data : []);
134
+ return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
135
+ }
136
+
137
+ if (name === "get_task") {
138
+ const id = String(args?.id ?? "");
139
+ const data = await apiGet<unknown>(`/api/tasks/${id}`);
140
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
141
+ }
142
+
143
+ if (name === "search_tasks") {
144
+ const query = String(args?.query ?? "");
145
+ const limit = args?.limit ? String(args.limit) : "10";
146
+ const data = await apiGet<unknown>("/api/tasks/search", { q: query, limit });
147
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
148
+ }
149
+
150
+ if (name === "propose_task_change") {
151
+ const action = String(args?.action ?? "create");
152
+ const payload: Record<string, unknown> = {};
153
+ const previewParts: string[] = [];
154
+
155
+ if (action === "create") {
156
+ if (!args?.title) return { content: [{ type: "text", text: "Ошибка: title обязателен для создания задачи" }] };
157
+ payload.title = args.title;
158
+ previewParts.push(`Создать задачу: «${args.title}»`);
159
+ if (args?.dueDate) { payload.dueDate = args.dueDate; previewParts.push(`Срок: ${args.dueDate}`); }
160
+ if (args?.projectId) { payload.projectId = args.projectId; previewParts.push(`Проект: ${args.projectId}`); }
161
+ if (args?.todayDate) { payload.todayDate = args.todayDate; previewParts.push(`В «Сегодня»: ${args.todayDate}`); }
162
+ if (args?.priority) { payload.priority = args.priority; previewParts.push(`Приоритет: ${args.priority}`); }
163
+ if (args?.descriptionHtml) payload.descriptionHtml = args.descriptionHtml;
164
+ } else {
165
+ const taskId = String(args?.taskId ?? "");
166
+ if (!taskId) return { content: [{ type: "text", text: "Ошибка: taskId обязателен для обновления" }] };
167
+ payload.taskId = taskId;
168
+ previewParts.push(`Изменить задачу ${taskId}`);
169
+ if (args?.title !== undefined) { payload.title = args.title; previewParts.push(`Новый заголовок: «${args.title}»`); }
170
+ if (args?.status) { payload.status = args.status; previewParts.push(`Статус: ${args.status}`); }
171
+ if (args?.dueDate !== undefined) { payload.dueDate = args.dueDate; previewParts.push(`Срок: ${args.dueDate ?? "убрать"}`); }
172
+ if (args?.todayDate !== undefined) { payload.todayDate = args.todayDate; }
173
+ if (args?.priority !== undefined) { payload.priority = args.priority; }
174
+ }
175
+
176
+ const token = storePending({ type: action === "create" ? "create_task" : "update_task", payload, preview: previewParts.join(", ") });
177
+ return {
178
+ content: [{
179
+ type: "text",
180
+ text: `📋 Preview: ${previewParts.join(" | ")}\n\nЧтобы применить, вызови confirm_task_change с токеном: ${token}\nТокен действителен 5 минут.`,
181
+ }],
182
+ };
183
+ }
184
+
185
+ if (name === "confirm_task_change") {
186
+ const token = String(args?.token ?? "");
187
+ const change = consumePending(token);
188
+ if (!change) return { content: [{ type: "text", text: "Токен не найден или истёк. Попроси новый preview." }] };
189
+
190
+ let result: unknown;
191
+ if (change.type === "create_task") {
192
+ result = await apiPost<unknown>("/api/tasks", change.payload);
193
+ } else {
194
+ const { taskId, ...rest } = change.payload;
195
+ result = await apiPatch<unknown>(`/api/tasks/${taskId}`, rest);
196
+ }
197
+ return { content: [{ type: "text", text: `✅ Готово: ${change.preview}\n\n${JSON.stringify(result, null, 2)}` }] };
198
+ }
199
+
200
+ if (name === "list_projects") {
201
+ const data = await apiGet<unknown>("/api/registry");
202
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
203
+ }
204
+
205
+ if (name === "search_knowledge") {
206
+ const query = String(args?.query ?? "");
207
+ const limit = args?.limit ? String(args.limit) : "8";
208
+ const data = await apiGet<unknown>("/api/knowledge/search", { q: query, limit });
209
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
210
+ }
211
+
212
+ if (name === "get_analytics") {
213
+ const type = String(args?.type ?? "summary");
214
+ const pathMap: Record<string, string> = {
215
+ summary: "/api/analytics/summary",
216
+ stuck: "/api/analytics/stuck-tasks",
217
+ deadline_risk: "/api/analytics/deadline-risk",
218
+ };
219
+ const apiPath = pathMap[type];
220
+ if (!apiPath) return { content: [{ type: "text", text: `Неизвестный тип: ${type}` }] };
221
+ const data = await apiGet<unknown>(apiPath);
222
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
223
+ }
224
+
225
+ return { content: [{ type: "text", text: `Неизвестный инструмент: ${name}` }] };
226
+ } catch (err) {
227
+ const msg = err instanceof Error ? err.message : String(err);
228
+ return { content: [{ type: "text", text: `Ошибка: ${msg}` }], isError: true };
229
+ }
230
+ });
231
+
232
+ const transport = new StdioServerTransport();
233
+ await server.connect(transport);
@@ -0,0 +1,29 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ type PendingChange = {
4
+ type: "create_task" | "update_task";
5
+ payload: Record<string, unknown>;
6
+ preview: string;
7
+ expiresAt: number;
8
+ };
9
+
10
+ const store = new Map<string, PendingChange>();
11
+
12
+ export function storePending(change: Omit<PendingChange, "expiresAt">): string {
13
+ const token = randomUUID();
14
+ store.set(token, { ...change, expiresAt: Date.now() + 5 * 60 * 1000 });
15
+ return token;
16
+ }
17
+
18
+ export function consumePending(token: string): PendingChange | null {
19
+ const change = store.get(token);
20
+ if (!change) return null;
21
+ if (change.expiresAt < Date.now()) { store.delete(token); return null; }
22
+ store.delete(token);
23
+ return change;
24
+ }
25
+
26
+ setInterval(() => {
27
+ const now = Date.now();
28
+ for (const [k, v] of store) if (v.expiresAt < now) store.delete(k);
29
+ }, 60_000);
package/src/setup.ts ADDED
@@ -0,0 +1,133 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import * as readline from "readline";
5
+
6
+ interface ClientInfo {
7
+ id: string;
8
+ label: string;
9
+ configPath: string;
10
+ }
11
+
12
+ function getConfigPath(client: string): string {
13
+ const home = os.homedir();
14
+ switch (client) {
15
+ case "claude":
16
+ if (process.platform === "win32")
17
+ return path.join(process.env.APPDATA ?? home, "Claude", "claude_desktop_config.json");
18
+ if (process.platform === "linux")
19
+ return path.join(home, ".config", "Claude", "claude_desktop_config.json");
20
+ return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
21
+ case "cursor":
22
+ return path.join(home, ".cursor", "mcp.json");
23
+ case "claudecode":
24
+ return path.join(home, ".claude.json");
25
+ default:
26
+ return "";
27
+ }
28
+ }
29
+
30
+ const CLIENTS: ClientInfo[] = [
31
+ { id: "claude", label: "Claude Desktop", configPath: getConfigPath("claude") },
32
+ { id: "cursor", label: "Cursor", configPath: getConfigPath("cursor") },
33
+ { id: "claudecode", label: "Claude Code", configPath: getConfigPath("claudecode") },
34
+ ];
35
+
36
+ function readHidden(prompt: string): Promise<string> {
37
+ return new Promise((resolve) => {
38
+ process.stdout.write(prompt);
39
+
40
+ if (!process.stdin.isTTY) {
41
+ const rl = readline.createInterface({ input: process.stdin });
42
+ rl.once("line", (line) => { rl.close(); resolve(line.trim()); });
43
+ return;
44
+ }
45
+
46
+ const chars: string[] = [];
47
+ const onData = (buf: Buffer) => {
48
+ const char = buf.toString("utf8");
49
+ if (char === "\n" || char === "\r" || char === "") {
50
+ process.stdin.setRawMode(false);
51
+ process.stdin.pause();
52
+ process.stdin.removeListener("data", onData);
53
+ process.stdout.write("\n");
54
+ resolve(chars.join(""));
55
+ } else if (char === "") {
56
+ process.stdout.write("\n");
57
+ process.exit(0);
58
+ } else if (char === "" || char === "\b") {
59
+ if (chars.length > 0) { chars.pop(); process.stdout.write("\b \b"); }
60
+ } else {
61
+ chars.push(char);
62
+ process.stdout.write("*");
63
+ }
64
+ };
65
+ process.stdin.setRawMode(true);
66
+ process.stdin.resume();
67
+ process.stdin.on("data", onData);
68
+ });
69
+ }
70
+
71
+ function ask(question: string, defaultVal = ""): Promise<string> {
72
+ return new Promise((resolve) => {
73
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
74
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim() || defaultVal); });
75
+ });
76
+ }
77
+
78
+ function mergeConfig(configPath: string, apiKey: string): void {
79
+ let config: Record<string, unknown> = {};
80
+ if (fs.existsSync(configPath)) {
81
+ try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { /* corrupt — start fresh */ }
82
+ }
83
+ const mcpServers = (config["mcpServers"] as Record<string, unknown>) ?? {};
84
+ mcpServers["senq"] = { command: "npx", args: ["senq-mcp"], env: { SENQ_API_KEY: apiKey } };
85
+ config["mcpServers"] = mcpServers;
86
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
87
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
88
+ }
89
+
90
+ export async function runSetup(): Promise<void> {
91
+ console.log("\n🤖 Настройка Senq MCP\n");
92
+
93
+ const apiKey = await readHidden("Вставь API-ключ из Senq: ");
94
+
95
+ if (!apiKey.startsWith("sk-senq-")) {
96
+ console.error("\n❌ Ключ должен начинаться с sk-senq-.\n Получи ключ в настройках Senq → API-ключи.\n");
97
+ process.exit(1);
98
+ }
99
+
100
+ console.log("\nДля какого клиента настроить?\n");
101
+ CLIENTS.forEach((c, i) => {
102
+ const exists = fs.existsSync(c.configPath);
103
+ console.log(` ${i + 1}) ${c.label}${exists ? " (конфиг найден)" : ""}`);
104
+ });
105
+ console.log(` ${CLIENTS.length + 1}) Все три\n`);
106
+
107
+ const choice = await ask("Введи номер [1]: ", "1");
108
+ const num = parseInt(choice, 10);
109
+
110
+ let selected: ClientInfo[];
111
+ if (num === CLIENTS.length + 1) {
112
+ selected = CLIENTS;
113
+ } else if (num >= 1 && num <= CLIENTS.length) {
114
+ selected = [CLIENTS[num - 1]];
115
+ } else {
116
+ selected = [CLIENTS[0]];
117
+ }
118
+
119
+ console.log("");
120
+ for (const client of selected) {
121
+ try {
122
+ mergeConfig(client.configPath, apiKey);
123
+ console.log(` ✅ ${client.label}: ${client.configPath}`);
124
+ } catch (err) {
125
+ const msg = err instanceof Error ? err.message : String(err);
126
+ console.error(` ❌ ${client.label}: ${msg}`);
127
+ }
128
+ }
129
+
130
+ const names = selected.map((c) => c.label).join(" и ");
131
+ console.log(`\n🎉 Готово! Перезапусти ${names}.\n`);
132
+ console.log(" Затем спроси: «Покажи мои задачи на сегодня»\n");
133
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true
11
+ },
12
+ "include": ["src"]
13
+ }