mcp-movidesk 0.1.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/.env.example ADDED
@@ -0,0 +1 @@
1
+ MOVIDESK_TOKEN=coloque-seu-token-aqui
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # Movidesk MCP Server
2
+
3
+ Servidor MCP em TypeScript/Bun para consultar tickets do Movidesk em modo somente leitura.
4
+
5
+ ## Ferramentas
6
+
7
+ - `get_ticket`: retorna dados principais do ticket e resumo das ultimas interacoes.
8
+ - `get_ticket_history`: retorna historico, comentarios, status e interacoes do ticket.
9
+ - `get_ticket_attachments`: retorna anexos/imagens associados ao ticket, com metadados, hash e URL de download sem token quando houver hash.
10
+
11
+ Nenhuma ferramenta cria, altera, exclui ou atualiza tickets.
12
+
13
+ ## Requisitos
14
+
15
+ - Bun instalado.
16
+ - Token da API Movidesk.
17
+
18
+ ## Instalacao
19
+
20
+ ```bash
21
+ git clone <url-do-repositorio> mcp-movidesk
22
+ cd mcp-movidesk
23
+ bun install
24
+ ```
25
+
26
+ ## Variaveis De Ambiente
27
+
28
+ Crie um arquivo `.env` ou configure a variavel no ambiente do MCP client:
29
+
30
+ ```bash
31
+ MOVIDESK_TOKEN=seu-token-da-api-movidesk
32
+ ```
33
+
34
+ O token nao deve ser versionado ou escrito no codigo.
35
+
36
+ Um arquivo `.env.example` esta incluido apenas como modelo.
37
+
38
+ ## Execucao
39
+
40
+ ```bash
41
+ bun run src/index.ts
42
+ ```
43
+
44
+ Ou pelo script:
45
+
46
+ ```bash
47
+ bun run start
48
+ ```
49
+
50
+ ## Configuracao MCP Client
51
+
52
+ Exemplo generico de configuracao para um cliente MCP via stdio:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "movidesk": {
58
+ "command": "bun",
59
+ "args": ["run", "C:/MCP Servers/Movidesk/src/index.ts"],
60
+ "env": {
61
+ "MOVIDESK_TOKEN": "seu-token-da-api-movidesk"
62
+ }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ## Configuracao OpenCode
69
+
70
+ Adicione o servidor no arquivo de configuracao do OpenCode, normalmente em `%USERPROFILE%\.opencode\opencode.json` no Windows.
71
+
72
+ Exemplo usando pacote publicado e `bunx`:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "movidesk": {
78
+ "command": "bunx",
79
+ "args": ["mcp-movidesk@latest"],
80
+ "env": {
81
+ "MOVIDESK_TOKEN": "seu-token-da-api-movidesk"
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Exemplo usando o script `start` a partir de um repositorio clonado:
89
+
90
+ ```json
91
+ {
92
+ "mcpServers": {
93
+ "movidesk": {
94
+ "command": "bun",
95
+ "args": ["run", "--cwd", "C:/caminho/para/mcp-movidesk", "start"],
96
+ "env": {
97
+ "MOVIDESK_TOKEN": "seu-token-da-api-movidesk"
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Depois de alterar a configuracao, reinicie o OpenCode e teste pedindo uma consulta de ticket numerico.
105
+
106
+ ## Publicacao
107
+
108
+ Para publicar como pacote executavel e usar via `bunx`:
109
+
110
+ ```bash
111
+ bun install
112
+ bun run build
113
+ npm publish
114
+ ```
115
+
116
+ Depois de publicado, o OpenCode pode iniciar o MCP com `bunx mcp-movidesk@latest`.
117
+
118
+ Antes de publicar, ajuste o `name` no `package.json` se quiser usar um pacote escopado, por exemplo `@sua-org/mcp-movidesk`. Nesse caso, a configuracao fica `"args": ["@sua-org/mcp-movidesk@latest"]`.
119
+
120
+ Arquivos recomendados para versionar:
121
+
122
+ - `bin/`
123
+ - `scripts/`
124
+ - `src/`
125
+ - `package.json`
126
+ - `bun.lock`
127
+ - `tsconfig.json`
128
+ - `README.md`
129
+ - `.env.example`
130
+ - `.gitignore`
131
+
132
+ Nao versione `.env`, tokens, logs ou `node_modules/`.
133
+
134
+ ## API Movidesk Utilizada
135
+
136
+ - Base URL: `https://api.movidesk.com/public/v1`
137
+ - Ticket por ID: `GET /tickets?token=TOKEN&id=TICKET_ID`
138
+ - Historico/interacoes: `GET /tickets` com `$expand=actions(...)`
139
+ - Anexos do ticket: `actions($expand=attachments)`
140
+ - Download de anexo: `GET /storage/download?token=TOKEN&id=HASH`; a ferramenta retorna apenas a URL sem `token` e o `hash`.
141
+
142
+ Observacoes da documentacao Movidesk:
143
+
144
+ - A rota `/tickets` cobre tickets com `lastUpdate` inferior a 90 dias; tickets antigos podem exigir `/tickets/past`.
145
+ - A API possui limite de 10 requisicoes por minuto.
146
+ - Em caso de bloqueio por falhas, a API pode retornar `429` e header `retry-after`.
147
+ - O servidor serializa chamadas para respeitar aproximadamente 10 requisicoes por minuto.
148
+
149
+ ## Validacao
150
+
151
+ ```bash
152
+ bun run typecheck
153
+ ```
154
+
155
+ ## Seguranca
156
+
157
+ - O servidor usa apenas `GET`.
158
+ - O token e lido exclusivamente de `MOVIDESK_TOKEN`.
159
+ - URLs de anexo retornadas pelas ferramentas nao incluem token; use o hash retornado com credenciais fora do historico MCP quando precisar baixar o arquivo.
160
+ - Logs sao minimos e nao exibem token.
161
+ - Respostas grandes sao truncadas/resumidas antes de retornar ao agente.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { MovideskClient } from "./movideskClient.js";
4
+ import { registerTools } from "./tools.js";
5
+ const token = process.env.MOVIDESK_TOKEN;
6
+ if (!token) {
7
+ console.error("MOVIDESK_TOKEN nao configurado.");
8
+ process.exit(1);
9
+ }
10
+ const server = new McpServer({
11
+ name: "mcp-movidesk",
12
+ version: "0.1.0"
13
+ });
14
+ const client = new MovideskClient({ token });
15
+ registerTools(server, client);
16
+ const transport = new StdioServerTransport();
17
+ await server.connect(transport);
18
+ console.error("Movidesk MCP server iniciado via stdio.");
@@ -0,0 +1,141 @@
1
+ const DEFAULT_BASE_URL = "https://api.movidesk.com/public/v1";
2
+ const DEFAULT_TIMEOUT_MS = 15_000;
3
+ const MIN_REQUEST_INTERVAL_MS = 6_100;
4
+ let nextRequestAt = 0;
5
+ export class MovideskApiError extends Error {
6
+ status;
7
+ retryAfterSeconds;
8
+ constructor(details) {
9
+ super(details.message);
10
+ this.name = "MovideskApiError";
11
+ this.status = details.status;
12
+ this.retryAfterSeconds = details.retryAfterSeconds;
13
+ }
14
+ }
15
+ export class MovideskClient {
16
+ baseUrl;
17
+ token;
18
+ timeoutMs;
19
+ constructor(options) {
20
+ this.token = options.token;
21
+ this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
22
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
23
+ }
24
+ async getTicket(ticketId) {
25
+ return this.getTicketWithFallback(ticketId, ticketExpand());
26
+ }
27
+ async getTicketHistory(ticketId) {
28
+ return this.getTicketWithFallback(ticketId, historyExpand());
29
+ }
30
+ async getTicketAttachments(ticketId) {
31
+ return this.getTicketWithFallback(ticketId, attachmentsExpand());
32
+ }
33
+ getAttachmentDownloadUrl(hash) {
34
+ const url = new URL(`${this.baseUrl}/storage/download`);
35
+ url.searchParams.set("id", hash);
36
+ return url.toString();
37
+ }
38
+ async getTicketWithFallback(ticketId, expand) {
39
+ try {
40
+ return await this.getTicketByRoute("tickets", ticketId, expand);
41
+ }
42
+ catch (error) {
43
+ if (error instanceof MovideskApiError && error.status === 404) {
44
+ return this.getTicketByRoute("tickets/past", ticketId, expand);
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+ async getTicketByRoute(route, ticketId, expand) {
50
+ const result = await this.get(`/${route}`, {
51
+ id: ticketId,
52
+ "$expand": expand
53
+ });
54
+ if (!result || typeof result !== "object") {
55
+ throw new MovideskApiError({ message: `Ticket ${ticketId} nao encontrado.` });
56
+ }
57
+ return result;
58
+ }
59
+ async get(path, params) {
60
+ await waitForRateLimit();
61
+ const url = new URL(`${this.baseUrl}${path}`);
62
+ url.searchParams.set("token", this.token);
63
+ for (const [key, value] of Object.entries(params)) {
64
+ url.searchParams.set(key, value);
65
+ }
66
+ const controller = new AbortController();
67
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
68
+ try {
69
+ const response = await fetch(url, {
70
+ method: "GET",
71
+ headers: { Accept: "application/json" },
72
+ signal: controller.signal
73
+ });
74
+ if (!response.ok) {
75
+ throw await toApiError(response);
76
+ }
77
+ return (await response.json());
78
+ }
79
+ catch (error) {
80
+ if (error instanceof MovideskApiError) {
81
+ throw error;
82
+ }
83
+ if (error instanceof DOMException && error.name === "AbortError") {
84
+ throw new MovideskApiError({ message: `Timeout ao consultar a API Movidesk apos ${this.timeoutMs}ms.` });
85
+ }
86
+ throw new MovideskApiError({ message: error instanceof Error ? error.message : "Erro desconhecido ao consultar a API Movidesk." });
87
+ }
88
+ finally {
89
+ clearTimeout(timeout);
90
+ }
91
+ }
92
+ }
93
+ function ticketExpand() {
94
+ return [
95
+ "owner",
96
+ "createdBy",
97
+ "clients",
98
+ "actions($select=id,type,origin,status,justification,createdDate,createdBy,description,htmlDescription,isDeleted)"
99
+ ].join(",");
100
+ }
101
+ function historyExpand() {
102
+ return [
103
+ "actions($select=id,type,origin,status,justification,createdDate,createdBy,description,htmlDescription,isDeleted,tags)",
104
+ "actions($expand=attachments)"
105
+ ].join(",");
106
+ }
107
+ function attachmentsExpand() {
108
+ return [
109
+ "actions($select=id,createdDate,createdBy)",
110
+ "actions($expand=attachments)"
111
+ ].join(",");
112
+ }
113
+ async function toApiError(response) {
114
+ const retryAfter = response.headers.get("retry-after");
115
+ await safeDrainBody(response);
116
+ const message = retryAfter
117
+ ? `Movidesk retornou HTTP ${response.status}. Tente novamente apos ${retryAfter}s.`
118
+ : `Movidesk retornou HTTP ${response.status}.`;
119
+ return new MovideskApiError({
120
+ status: response.status,
121
+ message,
122
+ retryAfterSeconds: retryAfter
123
+ });
124
+ }
125
+ async function safeDrainBody(response) {
126
+ try {
127
+ await response.text();
128
+ }
129
+ catch {
130
+ // Ignore body read failures while preserving the HTTP status error.
131
+ }
132
+ }
133
+ async function waitForRateLimit() {
134
+ const now = Date.now();
135
+ const scheduledAt = Math.max(now, nextRequestAt);
136
+ nextRequestAt = scheduledAt + MIN_REQUEST_INTERVAL_MS;
137
+ const delayMs = scheduledAt - now;
138
+ if (delayMs > 0) {
139
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
140
+ }
141
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+ import { MovideskApiError } from "./movideskClient.js";
3
+ const TicketInputShape = {
4
+ ticketId: z.string().trim().regex(/^\d{1,30}$/, "ticketId deve conter apenas numeros")
5
+ };
6
+ const MAX_TEXT_LENGTH = 2_000;
7
+ const MAX_ACTIONS = 40;
8
+ export function registerTools(server, client) {
9
+ const registrar = server;
10
+ registrar.tool("get_ticket", "Consulta dados principais de um ticket Movidesk por ID. Ferramenta somente leitura.", TicketInputShape, async (input) => asToolResult(async () => summarizeTicket(await client.getTicket(input.ticketId))));
11
+ registrar.tool("get_ticket_history", "Consulta historico, comentarios, status e interacoes de um ticket Movidesk. Ferramenta somente leitura.", TicketInputShape, async (input) => asToolResult(async () => summarizeHistory(await client.getTicketHistory(input.ticketId))));
12
+ registrar.tool("get_ticket_attachments", "Lista anexos e imagens associados as acoes de um ticket Movidesk, incluindo metadados e URL de download sem token quando ha hash. Ferramenta somente leitura.", TicketInputShape, async (input) => asToolResult(async () => summarizeAttachments(await client.getTicketAttachments(input.ticketId), client)));
13
+ }
14
+ async function asToolResult(read) {
15
+ try {
16
+ const data = await read();
17
+ return {
18
+ content: [{ type: "text", text: JSON.stringify({ ok: true, data }, null, 2) }]
19
+ };
20
+ }
21
+ catch (error) {
22
+ const data = formatError(error);
23
+ return {
24
+ isError: true,
25
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: data }, null, 2) }]
26
+ };
27
+ }
28
+ }
29
+ function summarizeTicket(ticket) {
30
+ const actions = Array.isArray(ticket.actions) ? ticket.actions : [];
31
+ return cleanObject({
32
+ id: ticket.id,
33
+ protocol: ticket.protocol,
34
+ subject: truncate(ticket.subject),
35
+ type: ticket.type,
36
+ status: ticket.status,
37
+ baseStatus: ticket.baseStatus,
38
+ justification: ticket.justification,
39
+ category: ticket.category,
40
+ urgency: ticket.urgency,
41
+ origin: ticket.origin,
42
+ createdDate: ticket.createdDate,
43
+ lastUpdate: ticket.lastUpdate,
44
+ lastActionDate: ticket.lastActionDate,
45
+ actionCount: ticket.actionCount,
46
+ ownerTeam: ticket.ownerTeam,
47
+ owner: summarizePerson(ticket.owner),
48
+ createdBy: summarizePerson(ticket.createdBy),
49
+ clients: ticket.clients?.map(summarizePerson),
50
+ serviceFull: ticket.serviceFull,
51
+ tags: ticket.tags,
52
+ dates: cleanObject({ resolvedIn: ticket.resolvedIn, reopenedIn: ticket.reopenedIn, closedIn: ticket.closedIn }),
53
+ recentActions: actions.slice(-5).map(summarizeAction),
54
+ note: actions.length > 5 ? `Retornadas as 5 acoes mais recentes de ${actions.length}. Use get_ticket_history para mais contexto.` : undefined
55
+ });
56
+ }
57
+ function summarizeHistory(ticket) {
58
+ const actions = Array.isArray(ticket.actions) ? ticket.actions : [];
59
+ const visibleActions = actions.slice(-MAX_ACTIONS);
60
+ return cleanObject({
61
+ ticketId: ticket.id,
62
+ subject: truncate(ticket.subject),
63
+ status: ticket.status,
64
+ totalActions: actions.length,
65
+ returnedActions: visibleActions.length,
66
+ truncated: actions.length > visibleActions.length,
67
+ actions: visibleActions.map(summarizeAction)
68
+ });
69
+ }
70
+ function summarizeAttachments(ticket, client) {
71
+ const actions = Array.isArray(ticket.actions) ? ticket.actions : [];
72
+ const attachments = actions.flatMap((action) => (action.attachments ?? []).map((attachment) => summarizeAttachment(attachment, action, client)));
73
+ return cleanObject({
74
+ ticketId: ticket.id,
75
+ subject: truncate(ticket.subject),
76
+ totalAttachments: attachments.length,
77
+ attachments
78
+ });
79
+ }
80
+ function summarizeAction(action) {
81
+ return cleanObject({
82
+ id: action.id,
83
+ type: action.type,
84
+ origin: action.origin,
85
+ status: action.status,
86
+ justification: action.justification,
87
+ createdDate: action.createdDate,
88
+ createdBy: summarizePerson(action.createdBy),
89
+ isDeleted: action.isDeleted,
90
+ description: truncate(stripHtml(action.description ?? action.htmlDescription ?? "")),
91
+ attachmentCount: action.attachments?.length ?? 0,
92
+ attachments: action.attachments?.map((attachment) => ({
93
+ fileName: attachment.fileName,
94
+ path: attachment.path,
95
+ createdDate: attachment.createdDate
96
+ })),
97
+ tags: action.tags
98
+ });
99
+ }
100
+ function summarizeAttachment(attachment, action, client) {
101
+ const hash = attachment.path?.trim();
102
+ return cleanObject({
103
+ fileName: attachment.fileName,
104
+ hash,
105
+ downloadUrl: hash ? client.getAttachmentDownloadUrl(hash) : undefined,
106
+ downloadRequiresToken: hash ? true : undefined,
107
+ createdDate: attachment.createdDate,
108
+ createdBy: summarizePerson(attachment.createdBy),
109
+ actionId: action.id,
110
+ actionCreatedDate: action.createdDate,
111
+ actionCreatedBy: summarizePerson(action.createdBy)
112
+ });
113
+ }
114
+ function summarizePerson(person) {
115
+ if (!person || typeof person !== "object") {
116
+ return undefined;
117
+ }
118
+ const value = person;
119
+ return cleanObject({
120
+ id: value.id,
121
+ businessName: value.businessName,
122
+ email: value.email,
123
+ personType: value.personType,
124
+ profileType: value.profileType
125
+ });
126
+ }
127
+ function truncate(value) {
128
+ if (typeof value !== "string") {
129
+ return undefined;
130
+ }
131
+ const normalized = value.replace(/\s+/g, " ").trim();
132
+ if (normalized.length <= MAX_TEXT_LENGTH) {
133
+ return normalized;
134
+ }
135
+ return `${normalized.slice(0, MAX_TEXT_LENGTH)}... [truncated]`;
136
+ }
137
+ function stripHtml(value) {
138
+ return value
139
+ .replace(/<[^>]*>/g, " ")
140
+ .replace(/&nbsp;/gi, " ")
141
+ .replace(/&amp;/gi, "&")
142
+ .replace(/&lt;/gi, "<")
143
+ .replace(/&gt;/gi, ">")
144
+ .replace(/&quot;/gi, '"')
145
+ .replace(/&#39;/g, "'");
146
+ }
147
+ function cleanObject(object) {
148
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== null));
149
+ }
150
+ function formatError(error) {
151
+ if (error instanceof MovideskApiError) {
152
+ return cleanObject({
153
+ type: error.name,
154
+ status: error.status,
155
+ message: error.message,
156
+ retryAfterSeconds: error.retryAfterSeconds
157
+ });
158
+ }
159
+ return {
160
+ type: "UnexpectedError",
161
+ message: error instanceof Error ? error.message : "Erro desconhecido."
162
+ };
163
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "mcp-movidesk",
3
+ "version": "0.1.0",
4
+ "description": "MCP server read-only para consultar tickets do Movidesk.",
5
+ "type": "module",
6
+ "private": false,
7
+ "bin": {
8
+ "mcp-movidesk": "./bin/mcp-movidesk.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "dist/",
13
+ "README.md",
14
+ ".env.example"
15
+ ],
16
+ "scripts": {
17
+ "start": "bun run src/index.ts",
18
+ "build": "tsc && bun run scripts/add-shebang.ts",
19
+ "prepack": "bun run build",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "1.29.0",
24
+ "zod": "3.25.76"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "1.3.13",
28
+ "typescript": "5.9.3"
29
+ }
30
+ }