@re9ti/timesheet-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.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @re9ti/timesheet-mcp
2
+
3
+ MCP Server para integrar o **Claude** (Code, Desktop, Web) com o **Timesheet Inteligente RE9TI**.
4
+
5
+ Lance timesheets, consulte horas, gerencie timers — tudo via linguagem natural no Claude.
6
+
7
+ ## Setup rápido
8
+
9
+ ```bash
10
+ npx @re9ti/timesheet-mcp setup
11
+ ```
12
+
13
+ O wizard vai:
14
+ 1. Pedir seu email e senha do Timesheet
15
+ 2. Validar suas credenciais
16
+ 3. Configurar o Claude Code e/ou Desktop automaticamente
17
+
18
+ Reinicie o Claude e pronto!
19
+
20
+ ## Uso
21
+
22
+ Depois de configurado, fale naturalmente com o Claude:
23
+
24
+ | Exemplo | O que faz |
25
+ |---------|-----------|
26
+ | "Lança 2h para cliente Silva, reunião de alinhamento" | Cria timesheet |
27
+ | "Quantas horas trabalhei hoje?" | Resumo do dia |
28
+ | "Lista meus lançamentos da semana" | Lista timesheets |
29
+ | "Inicia timer para projeto X" | Inicia cronômetro |
30
+ | "Para o timer" | Para e registra |
31
+ | "Quais clientes tenho?" | Lista clientes |
32
+
33
+ ## Ferramentas disponíveis
34
+
35
+ | Ferramenta | Descrição |
36
+ |------------|-----------|
37
+ | `login` | Autenticar no sistema |
38
+ | `criar_timesheet` | Criar lançamento |
39
+ | `listar_timesheets` | Listar lançamentos |
40
+ | `consultar_horas` | Resumo de horas + top apps |
41
+ | `listar_clientes` | Clientes disponíveis |
42
+ | `listar_contratos` | Contratos por cliente |
43
+ | `chat_timesheet` | Linguagem natural |
44
+ | `timer_iniciar` | Iniciar cronômetro |
45
+ | `timer_parar` | Parar cronômetro |
46
+ | `timer_pausar` | Pausar cronômetro |
47
+ | `timer_retomar` | Retomar cronômetro |
48
+ | `timer_status` | Status dos timers |
49
+
50
+ ## Segurança
51
+
52
+ - Sua senha é usada **apenas uma vez** durante o setup e **nunca é armazenada**
53
+ - O setup gera um **device token** (90 dias, revogável) que é salvo no config
54
+ - O admin pode revogar o token a qualquer momento no painel Dispositivos
55
+ - Quando o token expirar, re-execute `npx @re9ti/timesheet-mcp setup`
56
+
57
+ ## Configuração manual
58
+
59
+ Se preferir configurar manualmente, adicione ao `settings.json` do Claude Code (`~/.claude/settings.json`):
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "timesheet-re9ti": {
65
+ "command": "npx",
66
+ "args": ["-y", "@re9ti/timesheet-mcp"],
67
+ "env": {
68
+ "TIMESHEET_API_URL": "https://timesheet.re9ti.com.br",
69
+ "TIMESHEET_DEVICE_TOKEN": "seu-token-aqui"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ Para obter um token manualmente, use `POST /auth/login-device` na API.
77
+
78
+ ## Variáveis de ambiente
79
+
80
+ | Variável | Default | Descrição |
81
+ |----------|---------|-----------|
82
+ | `TIMESHEET_API_URL` | `https://timesheet.re9ti.com.br` | URL da API |
83
+ | `TIMESHEET_DEVICE_TOKEN` | — | Token de dispositivo (preferido) |
84
+ | `TIMESHEET_EMAIL` | — | Email (fallback legacy) |
85
+ | `TIMESHEET_PASSWORD` | — | Senha (fallback legacy) |
86
+
87
+ ## Requisitos
88
+
89
+ - Node.js >= 18
90
+ - Conta no Timesheet Inteligente RE9TI
package/bin/cli.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entry point for @re9ti/timesheet-mcp
5
+ *
6
+ * Usage:
7
+ * npx @re9ti/timesheet-mcp → run MCP server (stdio)
8
+ * npx @re9ti/timesheet-mcp setup → interactive setup wizard
9
+ */
10
+
11
+ import { createRequire } from "module";
12
+ import { fileURLToPath } from "url";
13
+ import { dirname, join } from "path";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+
17
+ const command = process.argv[2];
18
+
19
+ if (command === "setup") {
20
+ const setup = await import(join(__dirname, "..", "dist", "setup.js"));
21
+ setup.default();
22
+ } else {
23
+ // Default: run MCP server via stdio
24
+ await import(join(__dirname, "..", "dist", "index.js"));
25
+ }
package/dist/api.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * HTTP client for the Timesheet Inteligente API.
3
+ *
4
+ * Auth priority:
5
+ * 1. TIMESHEET_DEVICE_TOKEN (device token from setup wizard — preferred, no password stored)
6
+ * 2. TIMESHEET_EMAIL + TIMESHEET_PASSWORD (legacy fallback)
7
+ */
8
+ export declare function setCredentials(e: string, p: string): void;
9
+ export declare function setDeviceToken(token: string): void;
10
+ /**
11
+ * Obtain a device token via login-device endpoint.
12
+ * Used by the setup wizard — exchanges email+password for a long-lived revocable token.
13
+ */
14
+ export declare function obtainDeviceToken(loginEmail: string, loginPassword: string): Promise<{
15
+ device_token: string;
16
+ user_id: string;
17
+ org_id: string;
18
+ user_name: string;
19
+ user_email: string;
20
+ } | null>;
21
+ /**
22
+ * Login with email/password (legacy fallback).
23
+ */
24
+ export declare function login(overrideEmail?: string, overridePassword?: string): Promise<boolean>;
25
+ /**
26
+ * Make an authenticated API call.
27
+ * Uses device token (Bearer) if available, falls back to session cookie/token.
28
+ */
29
+ export declare function api(method: string, path: string, options?: {
30
+ params?: Record<string, string | number>;
31
+ body?: unknown;
32
+ }): Promise<Record<string, unknown>>;
33
+ export declare function getApiUrl(): string;
34
+ export declare function getEmail(): string;
35
+ export declare function isAuthenticated(): boolean;
36
+ export declare function hasDeviceToken(): boolean;
package/dist/api.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * HTTP client for the Timesheet Inteligente API.
3
+ *
4
+ * Auth priority:
5
+ * 1. TIMESHEET_DEVICE_TOKEN (device token from setup wizard — preferred, no password stored)
6
+ * 2. TIMESHEET_EMAIL + TIMESHEET_PASSWORD (legacy fallback)
7
+ */
8
+ import { randomUUID } from "crypto";
9
+ import { hostname } from "os";
10
+ const API_URL = process.env.TIMESHEET_API_URL || "https://timesheet.re9ti.com.br";
11
+ // Device token auth (preferred)
12
+ let deviceToken = process.env.TIMESHEET_DEVICE_TOKEN || null;
13
+ // Legacy email/password fallback
14
+ let email = process.env.TIMESHEET_EMAIL || "";
15
+ let password = process.env.TIMESHEET_PASSWORD || "";
16
+ // Session state
17
+ let authToken = null;
18
+ let authCookie = null;
19
+ export function setCredentials(e, p) {
20
+ email = e;
21
+ password = p;
22
+ }
23
+ export function setDeviceToken(token) {
24
+ deviceToken = token;
25
+ }
26
+ /**
27
+ * Obtain a device token via login-device endpoint.
28
+ * Used by the setup wizard — exchanges email+password for a long-lived revocable token.
29
+ */
30
+ export async function obtainDeviceToken(loginEmail, loginPassword) {
31
+ const deviceId = `mcp-${randomUUID()}`;
32
+ const resp = await fetch(`${API_URL}/auth/login-device`, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({
36
+ email: loginEmail,
37
+ password: loginPassword,
38
+ device_id: deviceId,
39
+ hostname: `mcp-${hostname()}`,
40
+ }),
41
+ });
42
+ if (!resp.ok)
43
+ return null;
44
+ const data = (await resp.json());
45
+ // Store for immediate use
46
+ deviceToken = data.device_token;
47
+ return data;
48
+ }
49
+ /**
50
+ * Login with email/password (legacy fallback).
51
+ */
52
+ export async function login(overrideEmail, overridePassword) {
53
+ const loginEmail = overrideEmail || email;
54
+ const loginPassword = overridePassword || password;
55
+ if (!loginEmail || !loginPassword) {
56
+ return false;
57
+ }
58
+ const resp = await fetch(`${API_URL}/auth/login`, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ email: loginEmail, password: loginPassword }),
62
+ });
63
+ if (!resp.ok)
64
+ return false;
65
+ const data = (await resp.json());
66
+ authToken = data.token ?? null;
67
+ const setCookie = resp.headers.get("set-cookie");
68
+ if (setCookie) {
69
+ const match = setCookie.match(/att_session=([^;]+)/);
70
+ if (match)
71
+ authCookie = match[1];
72
+ }
73
+ if (overrideEmail)
74
+ email = overrideEmail;
75
+ if (overridePassword)
76
+ password = overridePassword;
77
+ return true;
78
+ }
79
+ /**
80
+ * Make an authenticated API call.
81
+ * Uses device token (Bearer) if available, falls back to session cookie/token.
82
+ */
83
+ export async function api(method, path, options = {}) {
84
+ // Auto-authenticate if needed
85
+ if (!deviceToken && !authToken && !authCookie) {
86
+ await login();
87
+ }
88
+ const url = new URL(path, API_URL);
89
+ if (options.params) {
90
+ for (const [k, v] of Object.entries(options.params)) {
91
+ if (v !== undefined && v !== null && v !== "") {
92
+ url.searchParams.set(k, String(v));
93
+ }
94
+ }
95
+ }
96
+ const headers = { "Content-Type": "application/json" };
97
+ // Auth priority: device token > cookie > JWT
98
+ if (deviceToken) {
99
+ headers["Authorization"] = `Bearer ${deviceToken}`;
100
+ }
101
+ else if (authCookie) {
102
+ headers["Cookie"] = `att_session=${authCookie}`;
103
+ }
104
+ else if (authToken) {
105
+ headers["Authorization"] = `Bearer ${authToken}`;
106
+ }
107
+ let resp = await fetch(url.toString(), {
108
+ method,
109
+ headers,
110
+ body: options.body ? JSON.stringify(options.body) : undefined,
111
+ });
112
+ // On 401, try re-login (only for legacy email/password mode)
113
+ if (resp.status === 401 && !deviceToken) {
114
+ const ok = await login();
115
+ if (ok) {
116
+ if (authCookie)
117
+ headers["Cookie"] = `att_session=${authCookie}`;
118
+ else if (authToken)
119
+ headers["Authorization"] = `Bearer ${authToken}`;
120
+ resp = await fetch(url.toString(), {
121
+ method,
122
+ headers,
123
+ body: options.body ? JSON.stringify(options.body) : undefined,
124
+ });
125
+ }
126
+ }
127
+ if (!resp.ok) {
128
+ const text = await resp.text();
129
+ return { error: true, status: resp.status, detail: text };
130
+ }
131
+ return (await resp.json());
132
+ }
133
+ export function getApiUrl() {
134
+ return API_URL;
135
+ }
136
+ export function getEmail() {
137
+ return email;
138
+ }
139
+ export function isAuthenticated() {
140
+ return !!(deviceToken || authToken || authCookie);
141
+ }
142
+ export function hasDeviceToken() {
143
+ return !!deviceToken;
144
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * MCP Server for Timesheet Inteligente — RE9TI
3
+ *
4
+ * Exposes timesheet management tools to Claude via Model Context Protocol.
5
+ * Runs over stdio transport (standard for Claude Code / Desktop).
6
+ */
7
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * MCP Server for Timesheet Inteligente — RE9TI
3
+ *
4
+ * Exposes timesheet management tools to Claude via Model Context Protocol.
5
+ * Runs over stdio transport (standard for Claude Code / Desktop).
6
+ */
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+ import { api, login, setCredentials, getEmail, hasDeviceToken } from "./api.js";
11
+ const server = new McpServer({
12
+ name: "timesheet-re9ti",
13
+ version: "1.0.0",
14
+ });
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+ function fmt(seconds) {
19
+ const h = Math.floor(seconds / 3600);
20
+ const m = Math.floor((seconds % 3600) / 60);
21
+ return `${h}h${m.toString().padStart(2, "0")}min`;
22
+ }
23
+ function today() {
24
+ return new Date().toISOString().slice(0, 10);
25
+ }
26
+ function nowHHMM() {
27
+ return new Date().toTimeString().slice(0, 5);
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Tools
31
+ // ---------------------------------------------------------------------------
32
+ server.tool("login", "Autenticar no sistema de timesheet. Normalmente desnecessário — o token de dispositivo do setup já autentica automaticamente. Use apenas se precisar trocar de conta.", { email: z.string().optional(), password: z.string().optional() }, async ({ email: e, password: p }) => {
33
+ if (hasDeviceToken()) {
34
+ return {
35
+ content: [
36
+ {
37
+ type: "text",
38
+ text: "Já autenticado via token de dispositivo (configurado no setup). Não é necessário login manual.",
39
+ },
40
+ ],
41
+ };
42
+ }
43
+ if (e)
44
+ setCredentials(e, p || "");
45
+ const ok = await login(e, p);
46
+ return {
47
+ content: [
48
+ {
49
+ type: "text",
50
+ text: ok
51
+ ? `Login realizado com sucesso como ${getEmail()}`
52
+ : "Falha no login. Verifique email e senha.",
53
+ },
54
+ ],
55
+ };
56
+ });
57
+ server.tool("criar_timesheet", `Criar um novo lançamento de timesheet.
58
+ Informe descricao + cliente + (hora_inicio/hora_fim OU duracao_minutos).
59
+ Data default = hoje. Horário default = agora.`, {
60
+ descricao: z.string().describe("Descrição do trabalho realizado"),
61
+ cliente: z.string().describe("Nome do cliente"),
62
+ data: z.string().optional().describe("Data YYYY-MM-DD (default: hoje)"),
63
+ hora_inicio: z.string().optional().describe("HH:MM início"),
64
+ hora_fim: z.string().optional().describe("HH:MM fim"),
65
+ duracao_minutos: z.number().optional().describe("Duração em minutos"),
66
+ projeto: z.string().optional().describe("Nome do projeto/contrato"),
67
+ cobravel: z.boolean().optional().describe("Hora cobrável (default: true)"),
68
+ profissional: z.string().optional().describe("Nome do profissional"),
69
+ referencia: z.string().optional().describe("Referência externa"),
70
+ }, async (args) => {
71
+ const targetDate = args.data || today();
72
+ let startedAt;
73
+ let endedAt;
74
+ let durationS;
75
+ if (args.hora_inicio && args.hora_fim) {
76
+ startedAt = `${targetDate}T${args.hora_inicio}:00`;
77
+ endedAt = `${targetDate}T${args.hora_fim}:00`;
78
+ durationS = (new Date(endedAt).getTime() - new Date(startedAt).getTime()) / 1000;
79
+ }
80
+ else if (args.duracao_minutos && args.duracao_minutos > 0) {
81
+ durationS = args.duracao_minutos * 60;
82
+ if (args.hora_inicio) {
83
+ startedAt = `${targetDate}T${args.hora_inicio}:00`;
84
+ const end = new Date(new Date(startedAt).getTime() + durationS * 1000);
85
+ endedAt = `${targetDate}T${end.toTimeString().slice(0, 5)}:00`;
86
+ }
87
+ else {
88
+ const now = new Date();
89
+ endedAt = `${targetDate}T${nowHHMM()}:00`;
90
+ const start = new Date(now.getTime() - durationS * 1000);
91
+ startedAt = `${targetDate}T${start.toTimeString().slice(0, 5)}:00`;
92
+ }
93
+ }
94
+ else {
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: "Erro: informe hora_inicio + hora_fim OU duracao_minutos.",
100
+ },
101
+ ],
102
+ };
103
+ }
104
+ if (durationS <= 0) {
105
+ return {
106
+ content: [{ type: "text", text: "Erro: duração deve ser positiva." }],
107
+ };
108
+ }
109
+ const body = {
110
+ client_name: args.cliente,
111
+ description: args.descricao,
112
+ started_at: startedAt,
113
+ ended_at: endedAt,
114
+ duration_s: durationS,
115
+ billable: args.cobravel !== false,
116
+ source_type: "mcp",
117
+ status: "draft",
118
+ };
119
+ if (args.projeto)
120
+ body.project_name = args.projeto;
121
+ if (args.profissional)
122
+ body.professional_name = args.profissional;
123
+ if (args.referencia)
124
+ body.entry_reference = args.referencia;
125
+ const result = await api("POST", "/api/v1/timesheets", { body });
126
+ if (result.error) {
127
+ return {
128
+ content: [
129
+ { type: "text", text: `Erro ao criar timesheet: ${result.detail}` },
130
+ ],
131
+ };
132
+ }
133
+ return {
134
+ content: [
135
+ {
136
+ type: "text",
137
+ text: [
138
+ "Timesheet criado com sucesso!",
139
+ `- ID: ${result.timesheet_id}`,
140
+ `- Cliente: ${args.cliente}`,
141
+ `- Data: ${targetDate}`,
142
+ `- Duração: ${fmt(durationS)}`,
143
+ `- Cobrável: ${args.cobravel !== false ? "Sim" : "Não"}`,
144
+ `- Status: rascunho`,
145
+ ].join("\n"),
146
+ },
147
+ ],
148
+ };
149
+ });
150
+ server.tool("listar_timesheets", "Listar lançamentos de timesheet com filtros por data, cliente, profissional.", {
151
+ data_inicio: z.string().optional().describe("Data inicial YYYY-MM-DD (default: hoje)"),
152
+ data_fim: z.string().optional().describe("Data final YYYY-MM-DD (default: hoje)"),
153
+ cliente: z.string().optional().describe("Filtrar por cliente"),
154
+ profissional: z.string().optional().describe("Filtrar por profissional"),
155
+ fonte: z.string().optional().describe("local | glpi | both (default: local)"),
156
+ limite: z.number().optional().describe("Máximo de resultados (default: 20)"),
157
+ }, async (args) => {
158
+ const params = {
159
+ date_from: args.data_inicio || today(),
160
+ date_to: args.data_fim || today(),
161
+ limit: args.limite || 20,
162
+ };
163
+ if (args.cliente)
164
+ params.client_name = args.cliente;
165
+ if (args.profissional)
166
+ params.professional_name = args.profissional;
167
+ if (args.fonte && args.fonte !== "local")
168
+ params.source = args.fonte;
169
+ const result = await api("GET", "/api/v1/timesheets", { params });
170
+ if (result.error) {
171
+ return {
172
+ content: [{ type: "text", text: `Erro: ${result.detail}` }],
173
+ };
174
+ }
175
+ const entries = (result.timesheets || result.entries || []);
176
+ if (!entries.length) {
177
+ return {
178
+ content: [{ type: "text", text: "Nenhum lançamento encontrado." }],
179
+ };
180
+ }
181
+ let totalS = 0;
182
+ const lines = [`**${entries.length} lançamento(s):**\n`];
183
+ for (const ts of entries) {
184
+ const dur = ts.duration_s || 0;
185
+ totalS += dur;
186
+ const billable = ts.billable ? "$$" : "--";
187
+ lines.push(`- [${billable}] ${ts.date || "?"} ${ts.time_start || "?"}-${ts.time_end || "?"} (${fmt(dur)}) | ${ts.client_name || "?"} | ${(ts.description || "").slice(0, 60)} [${ts.status}]`);
188
+ }
189
+ lines.push(`\n**Total: ${fmt(totalS)}**`);
190
+ return { content: [{ type: "text", text: lines.join("\n") }] };
191
+ });
192
+ server.tool("consultar_horas", "Consultar resumo de horas trabalhadas no período, com top apps.", {
193
+ data_inicio: z.string().optional().describe("YYYY-MM-DD (default: hoje)"),
194
+ data_fim: z.string().optional().describe("YYYY-MM-DD (default: hoje)"),
195
+ }, async (args) => {
196
+ const params = {
197
+ date_from: args.data_inicio || today(),
198
+ date_to: args.data_fim || today(),
199
+ };
200
+ const result = await api("GET", "/api/v1/timesheet", { params });
201
+ if (result.error) {
202
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
203
+ }
204
+ const total = result.total_seconds || 0;
205
+ const active = result.active_seconds || 0;
206
+ const idle = result.idle_seconds || 0;
207
+ const entries = (result.entries || []);
208
+ const appTotals = {};
209
+ for (const e of entries) {
210
+ const app = e.app_name || "?";
211
+ appTotals[app] = (appTotals[app] || 0) + (e.total_seconds || 0);
212
+ }
213
+ const lines = [
214
+ `**Resumo (${params.date_from} a ${params.date_to}):**\n`,
215
+ `- Total capturado: ${fmt(total)}`,
216
+ `- Ativo: ${fmt(active)}`,
217
+ `- Ocioso: ${fmt(idle)}`,
218
+ `- Apps distintos: ${new Set(entries.map((e) => e.app_name)).size}`,
219
+ ];
220
+ const sorted = Object.entries(appTotals).sort((a, b) => b[1] - a[1]);
221
+ if (sorted.length) {
222
+ lines.push("\n**Top apps:**");
223
+ for (const [app, secs] of sorted.slice(0, 8)) {
224
+ lines.push(`- ${app}: ${fmt(secs)}`);
225
+ }
226
+ }
227
+ return { content: [{ type: "text", text: lines.join("\n") }] };
228
+ });
229
+ server.tool("listar_clientes", "Listar clientes disponíveis (GLPI + manuais).", {}, async () => {
230
+ const result = await api("GET", "/api/v1/glpi/clients");
231
+ if (result.error) {
232
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
233
+ }
234
+ const clients = (Array.isArray(result) ? result : result.clients || []);
235
+ if (!clients.length) {
236
+ return { content: [{ type: "text", text: "Nenhum cliente encontrado." }] };
237
+ }
238
+ const lines = [`**${clients.length} cliente(s):**\n`];
239
+ for (const c of clients.slice(0, 50)) {
240
+ lines.push(`- [${c.id}] ${c.name || c.completename}`);
241
+ }
242
+ if (clients.length > 50)
243
+ lines.push(`\n... e mais ${clients.length - 50}`);
244
+ return { content: [{ type: "text", text: lines.join("\n") }] };
245
+ });
246
+ server.tool("listar_contratos", "Listar contratos disponíveis, opcionalmente filtrados por cliente.", { cliente_id: z.number().optional().describe("ID do cliente (0 = todos)") }, async (args) => {
247
+ const params = {};
248
+ if (args.cliente_id)
249
+ params.entity_id = args.cliente_id;
250
+ const result = await api("GET", "/api/v1/glpi/contracts", { params });
251
+ if (result.error) {
252
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
253
+ }
254
+ const contracts = (Array.isArray(result) ? result : result.contracts || []);
255
+ if (!contracts.length) {
256
+ return { content: [{ type: "text", text: "Nenhum contrato encontrado." }] };
257
+ }
258
+ const lines = [`**${contracts.length} contrato(s):**\n`];
259
+ for (const c of contracts.slice(0, 30)) {
260
+ lines.push(`- [${c.id}] ${c.name}`);
261
+ }
262
+ return { content: [{ type: "text", text: lines.join("\n") }] };
263
+ });
264
+ server.tool("chat_timesheet", `Enviar mensagem em linguagem natural para criar/consultar timesheets.
265
+ Exemplos: "Lançar 2h para Silva, reunião" / "Quantas horas hoje?" / "Listar semana"`, { mensagem: z.string().describe("Mensagem em linguagem natural") }, async (args) => {
266
+ const result = await api("POST", "/api/v1/chat/message", {
267
+ body: { message: args.mensagem, mode: "direct" },
268
+ });
269
+ if (result.error) {
270
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
271
+ }
272
+ const reply = result.reply || "Sem resposta";
273
+ const action = result.action;
274
+ let text = reply;
275
+ if (action?.type === "created") {
276
+ text += `\n\nTimesheet criado com ID: ${action.timesheet_id}`;
277
+ }
278
+ else if (action?.type === "prefill") {
279
+ const data = action.data || {};
280
+ text += `\n\nDados extraídos:\n- Cliente: ${data.client_name}\n- Duração: ${Math.floor((data.duration_s || 0) / 60)} min\n- Descrição: ${data.description}\n\nUse criar_timesheet para confirmar.`;
281
+ }
282
+ return { content: [{ type: "text", text }] };
283
+ });
284
+ // --- Timer tools ---
285
+ server.tool("timer_iniciar", "Iniciar um timer/cronômetro de trabalho.", {
286
+ cliente: z.string().optional().describe("Nome do cliente"),
287
+ projeto: z.string().optional().describe("Nome do projeto"),
288
+ descricao: z.string().optional().describe("Descrição do trabalho"),
289
+ }, async (args) => {
290
+ const body = {};
291
+ if (args.cliente)
292
+ body.client_name = args.cliente;
293
+ if (args.projeto)
294
+ body.project_name = args.projeto;
295
+ if (args.descricao)
296
+ body.description = args.descricao;
297
+ const result = await api("POST", "/api/v1/timesheets/timer/start", { body });
298
+ if (result.error) {
299
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
300
+ }
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text",
305
+ text: `Timer iniciado!\n- ID: ${result.entry_id}\n- Início: ${result.started_at}\nUse timer_parar quando terminar.`,
306
+ },
307
+ ],
308
+ };
309
+ });
310
+ server.tool("timer_parar", "Parar um timer e converter em lançamento de timesheet.", { timer_id: z.string().optional().describe("ID do timer (vazio = timer ativo)") }, async (args) => {
311
+ let timerId = args.timer_id;
312
+ if (!timerId) {
313
+ const status = await api("GET", "/api/v1/timesheets/timer/status");
314
+ if (status.error) {
315
+ return { content: [{ type: "text", text: `Erro: ${status.detail}` }] };
316
+ }
317
+ const timers = status.timers || [];
318
+ const running = timers.find((t) => t.status === "running");
319
+ timerId = (running || timers[0])?.entry_id || "";
320
+ if (!timerId) {
321
+ return { content: [{ type: "text", text: "Nenhum timer ativo." }] };
322
+ }
323
+ }
324
+ const result = await api("POST", `/api/v1/timesheets/timer/stop/${timerId}`);
325
+ if (result.error) {
326
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
327
+ }
328
+ const dur = result.duration_s || 0;
329
+ return {
330
+ content: [
331
+ {
332
+ type: "text",
333
+ text: [
334
+ "Timer parado!",
335
+ `- Duração: ${fmt(dur)}`,
336
+ `- Horário: ${result.time_start} - ${result.time_end}`,
337
+ `- Cliente: ${result.client_name || "-"}`,
338
+ `- Descrição: ${result.description || "-"}`,
339
+ ].join("\n"),
340
+ },
341
+ ],
342
+ };
343
+ });
344
+ server.tool("timer_status", "Verificar status dos timers ativos.", {}, async () => {
345
+ const result = await api("GET", "/api/v1/timesheets/timer/status");
346
+ if (result.error) {
347
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
348
+ }
349
+ const timers = result.timers || [];
350
+ if (!timers.length) {
351
+ return { content: [{ type: "text", text: "Nenhum timer ativo." }] };
352
+ }
353
+ const lines = [`**${timers.length} timer(s):**\n`];
354
+ for (const t of timers) {
355
+ const elapsed = t.elapsed_s || 0;
356
+ const icon = t.status === "running" ? "▶" : "⏸";
357
+ lines.push(`- ${icon} [${(t.entry_id || "").slice(0, 8)}] ${fmt(elapsed)} | ${t.client_name || "-"} | ${t.description || "-"} (${t.status})`);
358
+ }
359
+ return { content: [{ type: "text", text: lines.join("\n") }] };
360
+ });
361
+ server.tool("timer_pausar", "Pausar um timer ativo.", { timer_id: z.string().describe("ID do timer") }, async (args) => {
362
+ const result = await api("POST", `/api/v1/timesheets/timer/pause/${args.timer_id}`);
363
+ if (result.error) {
364
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
365
+ }
366
+ return {
367
+ content: [
368
+ { type: "text", text: `Timer pausado. Acumulado: ${fmt(result.elapsed_s || 0)}` },
369
+ ],
370
+ };
371
+ });
372
+ server.tool("timer_retomar", "Retomar um timer pausado.", { timer_id: z.string().describe("ID do timer") }, async (args) => {
373
+ const result = await api("POST", `/api/v1/timesheets/timer/resume/${args.timer_id}`);
374
+ if (result.error) {
375
+ return { content: [{ type: "text", text: `Erro: ${result.detail}` }] };
376
+ }
377
+ return {
378
+ content: [{ type: "text", text: `Timer retomado! Status: ${result.status}` }],
379
+ };
380
+ });
381
+ // ---------------------------------------------------------------------------
382
+ // Start server
383
+ // ---------------------------------------------------------------------------
384
+ async function main() {
385
+ const transport = new StdioServerTransport();
386
+ await server.connect(transport);
387
+ }
388
+ main().catch((err) => {
389
+ console.error("Fatal:", err);
390
+ process.exit(1);
391
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Interactive setup wizard for @re9ti/timesheet-mcp.
3
+ *
4
+ * Security model:
5
+ * 1. User provides email + password ONCE during setup
6
+ * 2. We exchange them for a device token (90-day, revocable)
7
+ * 3. Only the device token is saved to config — password is NEVER stored
8
+ * 4. Token can be revoked by admin in the Timesheet dashboard
9
+ *
10
+ * Run: npx @re9ti/timesheet-mcp setup
11
+ */
12
+ export default function setup(): Promise<void>;
package/dist/setup.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Interactive setup wizard for @re9ti/timesheet-mcp.
3
+ *
4
+ * Security model:
5
+ * 1. User provides email + password ONCE during setup
6
+ * 2. We exchange them for a device token (90-day, revocable)
7
+ * 3. Only the device token is saved to config — password is NEVER stored
8
+ * 4. Token can be revoked by admin in the Timesheet dashboard
9
+ *
10
+ * Run: npx @re9ti/timesheet-mcp setup
11
+ */
12
+ import { createInterface } from "readline";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
14
+ import { homedir, hostname } from "os";
15
+ import { join } from "path";
16
+ import { randomUUID } from "crypto";
17
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
18
+ function ask(question, defaultVal = "") {
19
+ const suffix = defaultVal ? ` [${defaultVal}]` : "";
20
+ return new Promise((resolve) => {
21
+ rl.question(`${question}${suffix}: `, (answer) => {
22
+ resolve(answer.trim() || defaultVal);
23
+ });
24
+ });
25
+ }
26
+ function banner() {
27
+ console.log(`
28
+ ╔══════════════════════════════════════════════════╗
29
+ ║ Timesheet Inteligente — MCP Setup ║
30
+ ║ Integração com Claude Code / Desktop ║
31
+ ╚══════════════════════════════════════════════════╝
32
+ `);
33
+ }
34
+ function getClaudeCodeSettingsPath() {
35
+ return join(homedir(), ".claude", "settings.json");
36
+ }
37
+ function getClaudeDesktopConfigPath() {
38
+ const platform = process.platform;
39
+ if (platform === "win32") {
40
+ return join(process.env.APPDATA || "", "Claude", "claude_desktop_config.json");
41
+ }
42
+ else if (platform === "darwin") {
43
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
44
+ }
45
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
46
+ }
47
+ function readJsonFile(path) {
48
+ if (!existsSync(path))
49
+ return {};
50
+ try {
51
+ return JSON.parse(readFileSync(path, "utf-8"));
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ function writeJsonFile(path, data) {
58
+ const dir = join(path, "..");
59
+ if (!existsSync(dir))
60
+ mkdirSync(dir, { recursive: true });
61
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
62
+ }
63
+ async function obtainDeviceToken(apiUrl, email, password) {
64
+ const deviceId = `mcp-${randomUUID()}`;
65
+ const resp = await fetch(`${apiUrl}/auth/login-device`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({
69
+ email,
70
+ password,
71
+ device_id: deviceId,
72
+ hostname: `mcp-${hostname()}`,
73
+ }),
74
+ });
75
+ if (!resp.ok)
76
+ return null;
77
+ return (await resp.json());
78
+ }
79
+ export default async function setup() {
80
+ banner();
81
+ // 1. Collect credentials (used only once, never stored)
82
+ const apiUrl = await ask("URL da API do Timesheet", "https://timesheet.re9ti.com.br");
83
+ const email = await ask("Seu email de login");
84
+ const password = await ask("Sua senha");
85
+ if (!email || !password) {
86
+ console.log("\n Email e senha sao obrigatorios.");
87
+ rl.close();
88
+ process.exit(1);
89
+ }
90
+ // 2. Exchange credentials for device token
91
+ console.log("\nAutenticando e obtendo token de dispositivo...");
92
+ let deviceTokenData = null;
93
+ try {
94
+ deviceTokenData = await obtainDeviceToken(apiUrl, email, password);
95
+ }
96
+ catch (err) {
97
+ console.log(` Erro de conexao: ${err}`);
98
+ rl.close();
99
+ process.exit(1);
100
+ }
101
+ if (!deviceTokenData) {
102
+ console.log(" Login falhou. Verifique email e senha.");
103
+ rl.close();
104
+ process.exit(1);
105
+ }
106
+ console.log(` Autenticado! Bem-vindo, ${deviceTokenData.user_name || email}`);
107
+ console.log(" Token de dispositivo obtido (90 dias, revogavel pelo admin).");
108
+ console.log(" Sua senha NAO sera armazenada.\n");
109
+ // 3. Choose target
110
+ console.log("Onde deseja configurar?\n");
111
+ console.log(" 1) Claude Code (global — recomendado)");
112
+ console.log(" 2) Claude Desktop");
113
+ console.log(" 3) Ambos");
114
+ const choice = await ask("Escolha", "1");
115
+ // Only device token + API URL are stored — no email, no password
116
+ const mcpConfig = {
117
+ command: "npx",
118
+ args: ["-y", "@re9ti/timesheet-mcp"],
119
+ env: {
120
+ TIMESHEET_API_URL: apiUrl,
121
+ TIMESHEET_DEVICE_TOKEN: deviceTokenData.device_token,
122
+ },
123
+ };
124
+ const targets = [];
125
+ if (choice === "1" || choice === "3") {
126
+ const ccPath = getClaudeCodeSettingsPath();
127
+ const settings = readJsonFile(ccPath);
128
+ if (!settings.mcpServers)
129
+ settings.mcpServers = {};
130
+ settings.mcpServers["timesheet-re9ti"] = mcpConfig;
131
+ writeJsonFile(ccPath, settings);
132
+ targets.push(`Claude Code: ${ccPath}`);
133
+ }
134
+ if (choice === "2" || choice === "3") {
135
+ const cdPath = getClaudeDesktopConfigPath();
136
+ const config = readJsonFile(cdPath);
137
+ if (!config.mcpServers)
138
+ config.mcpServers = {};
139
+ config.mcpServers["timesheet-re9ti"] = mcpConfig;
140
+ writeJsonFile(cdPath, config);
141
+ targets.push(`Claude Desktop: ${cdPath}`);
142
+ }
143
+ // 4. Done
144
+ console.log("\n Configuracao salva em:");
145
+ for (const t of targets) {
146
+ console.log(` ${t}`);
147
+ }
148
+ console.log(`
149
+ +--------------------------------------------------+
150
+ | Pronto! Reinicie o Claude Code / Desktop. |
151
+ | |
152
+ | Depois, basta dizer: |
153
+ | "Lanca 2h para cliente Silva, reuniao" |
154
+ | "Quantas horas trabalhei hoje?" |
155
+ | "Inicia timer para projeto X" |
156
+ | |
157
+ | Seguranca: |
158
+ | - Apenas o token de dispositivo foi salvo |
159
+ | - Sua senha NAO esta armazenada em nenhum lugar |
160
+ | - Token expira em 90 dias (re-execute o setup) |
161
+ | - Admin pode revogar o token a qualquer momento |
162
+ +--------------------------------------------------+
163
+ `);
164
+ rl.close();
165
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@re9ti/timesheet-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP Server para integrar Claude com o Timesheet Inteligente RE9TI",
5
+ "keywords": ["mcp", "timesheet", "claude", "re9ti"],
6
+ "license": "MIT",
7
+ "author": "RE9TI <contato@re9ti.com.br>",
8
+ "type": "module",
9
+ "bin": {
10
+ "re9ti-timesheet-mcp": "./bin/cli.js"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "files": [
14
+ "dist",
15
+ "bin",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "dev": "tsc --watch",
21
+ "start": "node dist/index.js",
22
+ "setup": "node dist/setup.js",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.12.0",
27
+ "zod": "^3.23.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "typescript": "^5.5.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ }
36
+ }