@facturador-mcp-sii.cl/mcp 0.2.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 +113 -0
- package/dist/http.js +97 -0
- package/dist/index.js +21 -0
- package/dist/oauth.js +180 -0
- package/dist/server.js +194 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Facturador Pulsando — Servidor MCP
|
|
2
|
+
|
|
3
|
+
Servidor [Model Context Protocol](https://modelcontextprotocol.io) para la API de
|
|
4
|
+
**facturación electrónica del SII de Chile** de Facturador Pulsando. Conecta un
|
|
5
|
+
agente de IA (Claude Desktop, Claude Code, Cursor, etc.) directamente a tu
|
|
6
|
+
facturación: emitir y consultar DTE, consultar el padrón del SII y ver folios,
|
|
7
|
+
en lenguaje natural.
|
|
8
|
+
|
|
9
|
+
> *"Emite una boleta de $5.000 por consultoría"* · *"¿Cuántas facturas rechazadas
|
|
10
|
+
> tengo este mes y por qué?"* · *"Valida el RUT 76.354.771-K y crea una factura."*
|
|
11
|
+
|
|
12
|
+
## Herramientas expuestas
|
|
13
|
+
|
|
14
|
+
| Herramienta | Qué hace | Scope de la key |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `emitir_dte` | Emite factura/boleta/NC/ND/guía (con Idempotency-Key automática) | `dte:create` |
|
|
17
|
+
| `consultar_dte` | Detalle y estado SII de un DTE | `dte:read` |
|
|
18
|
+
| `listar_dtes` | Lista/filtra DTE (reportes) | `dte:read` |
|
|
19
|
+
| `consultar_contribuyente` | Padrón SII por RUT (razón social + giros) | `dte:read` |
|
|
20
|
+
| `estado_folios` | Folios (CAF) disponibles por tipo | `caf:read` |
|
|
21
|
+
| `documentos_recibidos` | Documentos que te emitieron (intercambio) | `recepcion:read` |
|
|
22
|
+
|
|
23
|
+
## Instalación
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install
|
|
27
|
+
npm run build
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuración (Claude Desktop / Claude Code / Cursor)
|
|
31
|
+
|
|
32
|
+
Agrega a tu config de MCP (en Claude Desktop: `claude_desktop_config.json`):
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"facturador-pulsando": {
|
|
38
|
+
"command": "node",
|
|
39
|
+
"args": ["/ruta/absoluta/a/facturador-mcp/dist/index.js"],
|
|
40
|
+
"env": {
|
|
41
|
+
"PULSANDO_API_KEY": "sk_test_...",
|
|
42
|
+
"PULSANDO_BASE_URL": "https://api.facturador.pulsandotech.cl/api/public/v1"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Una vez publicado en npm, también:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"facturador-pulsando": {
|
|
55
|
+
"command": "npx",
|
|
56
|
+
"args": ["-y", "@facturador-mcp-sii.cl/mcp"],
|
|
57
|
+
"env": { "PULSANDO_API_KEY": "sk_test_..." }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Variables de entorno
|
|
64
|
+
|
|
65
|
+
| Variable | Obligatoria | Default |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `PULSANDO_API_KEY` | Sí | — (`sk_test_...` para pruebas / `sk_live_...` producción) |
|
|
68
|
+
| `PULSANDO_BASE_URL` | No | `https://api.facturador.pulsandotech.cl/api/public/v1` |
|
|
69
|
+
|
|
70
|
+
## Seguridad
|
|
71
|
+
|
|
72
|
+
- El agente actúa con **los permisos (scopes) de la key** que le entregues. Para
|
|
73
|
+
pruebas usa una `sk_test_` (ambiente de certificación, documentos no válidos).
|
|
74
|
+
- Da a la key **solo los scopes** que el agente necesite.
|
|
75
|
+
- La key va en el `env` del servidor MCP (tu máquina/servidor), nunca en el chat.
|
|
76
|
+
|
|
77
|
+
## Servidor remoto (Streamable HTTP)
|
|
78
|
+
|
|
79
|
+
Además del stdio local, este paquete incluye un servidor **HTTP remoto**
|
|
80
|
+
(`src/http.ts`, Streamable HTTP, **stateless**) ya desplegado en Railway:
|
|
81
|
+
|
|
82
|
+
- **Endpoint:** `https://facturador-mcp-production.up.railway.app/mcp`
|
|
83
|
+
- **Healthcheck:** `GET /health`
|
|
84
|
+
- **Auth:** cada request trae la API Key del usuario en
|
|
85
|
+
`Authorization: Bearer sk_live_xxx` (o `X-Api-Key`). El agente actúa con esa
|
|
86
|
+
llave → paywall y scopes del tenant se respetan. Sin llave → `401`.
|
|
87
|
+
|
|
88
|
+
Ejecutarlo localmente:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run build
|
|
92
|
+
PORT=8080 npm run start:http
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Añadirlo como **custom connector remoto** (Claude Code, Cursor, MCP Inspector):
|
|
96
|
+
apunta la URL `https://facturador-mcp-production.up.railway.app/mcp` y pasa tu
|
|
97
|
+
API Key como Bearer token.
|
|
98
|
+
|
|
99
|
+
Desplegar en Railway (ya hecho; para re-deploy):
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
railway up --service facturador-mcp # usa railway.json (start: node dist/http.js)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Directorio de conectores de claude.ai (OAuth 2.1)
|
|
106
|
+
|
|
107
|
+
Para aparecer en el **directorio** de conectores de claude.ai se exige **OAuth 2.1
|
|
108
|
+
(PKCE)** en vez de Bearer manual: el usuario conecta su cuenta Pulsando y autoriza.
|
|
109
|
+
Eso requiere que el backend (Laravel) actúe como Authorization Server. Ver
|
|
110
|
+
`docs/api/AI-INTEGRATION.md` → "OAuth para el directorio" para el plan.
|
|
111
|
+
|
|
112
|
+
## Licencia
|
|
113
|
+
MIT · soporte@pulsandotech.cl
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Servidor MCP de Facturador Pulsando — transporte HTTP remoto (Streamable HTTP).
|
|
4
|
+
*
|
|
5
|
+
* Pensado para desplegarse (ej. Railway) y conectarse como "custom connector"
|
|
6
|
+
* remoto. Modo STATELESS: cada request crea su propio server+transport, así
|
|
7
|
+
* escala horizontalmente sin estado de sesión.
|
|
8
|
+
*
|
|
9
|
+
* Autenticación: cada request trae la API Key del usuario en el header
|
|
10
|
+
* Authorization: Bearer sk_live_xxx (o X-Api-Key: sk_live_xxx)
|
|
11
|
+
* El agente actúa con esa llave → el paywall y los scopes del tenant se respetan.
|
|
12
|
+
* (Para el DIRECTORIO de conectores de claude.ai se requiere además OAuth 2.1;
|
|
13
|
+
* ver README → "Despliegue remoto / OAuth".)
|
|
14
|
+
*
|
|
15
|
+
* Config:
|
|
16
|
+
* PORT puerto HTTP (Railway lo inyecta)
|
|
17
|
+
* PULSANDO_BASE_URL base de la API (opcional)
|
|
18
|
+
*/
|
|
19
|
+
import express from "express";
|
|
20
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
|
+
import { buildServer, DEFAULT_BASE_URL } from "./server.js";
|
|
22
|
+
import { mountOAuth, resolveToken } from "./oauth.js";
|
|
23
|
+
const PORT = Number(process.env.PORT ?? 8080);
|
|
24
|
+
const BASE_URL = (process.env.PULSANDO_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
25
|
+
/**
|
|
26
|
+
* Resuelve la API Key efectiva de un request:
|
|
27
|
+
* - Bearer que es un access_token OAuth → la llave mapeada (flujo claude.ai).
|
|
28
|
+
* - Bearer que es una API Key cruda sk_... → se usa directo (conector manual).
|
|
29
|
+
* - Header X-Api-Key: sk_... → directo.
|
|
30
|
+
*/
|
|
31
|
+
function extractApiKey(req) {
|
|
32
|
+
const auth = req.header("authorization") ?? "";
|
|
33
|
+
const m = auth.match(/^Bearer\s+(.+)$/i);
|
|
34
|
+
if (m) {
|
|
35
|
+
const bearer = m[1].trim();
|
|
36
|
+
const mapped = resolveToken(bearer);
|
|
37
|
+
if (mapped)
|
|
38
|
+
return mapped;
|
|
39
|
+
if (bearer.startsWith("sk_"))
|
|
40
|
+
return bearer;
|
|
41
|
+
}
|
|
42
|
+
const x = req.header("x-api-key");
|
|
43
|
+
return x && x.startsWith("sk_") ? x.trim() : null;
|
|
44
|
+
}
|
|
45
|
+
const app = express();
|
|
46
|
+
app.use(express.json({ limit: "1mb" }));
|
|
47
|
+
// OAuth (/authorize, /token) y el form de consentimiento usan x-www-form-urlencoded.
|
|
48
|
+
app.use(express.urlencoded({ extended: false }));
|
|
49
|
+
// Endpoints OAuth 2.1 (metadata, DCR, authorize, token) para claude.ai.
|
|
50
|
+
mountOAuth(app, BASE_URL);
|
|
51
|
+
// Healthcheck (Railway).
|
|
52
|
+
app.get("/health", (_req, res) => res.json({ ok: true, service: "facturador-mcp" }));
|
|
53
|
+
app.post("/mcp", async (req, res) => {
|
|
54
|
+
const apiKey = extractApiKey(req);
|
|
55
|
+
if (!apiKey) {
|
|
56
|
+
// Advertir el Authorization Server para que el cliente (claude.ai) inicie OAuth.
|
|
57
|
+
const proto = req.headers["x-forwarded-proto"] || req.protocol;
|
|
58
|
+
const base = process.env.PUBLIC_URL?.replace(/\/$/, "") || `${proto}://${req.get("host")}`;
|
|
59
|
+
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`);
|
|
60
|
+
res.status(401).json({
|
|
61
|
+
jsonrpc: "2.0",
|
|
62
|
+
error: { code: -32001, message: "No autorizado. Usa OAuth o Authorization: Bearer sk_..." },
|
|
63
|
+
id: null,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const server = buildServer(apiKey, BASE_URL);
|
|
69
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
70
|
+
res.on("close", () => {
|
|
71
|
+
transport.close();
|
|
72
|
+
server.close();
|
|
73
|
+
});
|
|
74
|
+
await server.connect(transport);
|
|
75
|
+
await transport.handleRequest(req, res, req.body);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (!res.headersSent) {
|
|
79
|
+
res.status(500).json({
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
error: { code: -32603, message: `Error interno: ${e.message}` },
|
|
82
|
+
id: null,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
// En modo stateless no hay sesiones que reabrir/cerrar por GET/DELETE.
|
|
88
|
+
const methodNotAllowed = (_req, res) => res.status(405).json({
|
|
89
|
+
jsonrpc: "2.0",
|
|
90
|
+
error: { code: -32000, message: "Method not allowed (servidor stateless)." },
|
|
91
|
+
id: null,
|
|
92
|
+
});
|
|
93
|
+
app.get("/mcp", methodNotAllowed);
|
|
94
|
+
app.delete("/mcp", methodNotAllowed);
|
|
95
|
+
app.listen(PORT, () => {
|
|
96
|
+
console.error(`[facturador-mcp] HTTP MCP escuchando en :${PORT} (POST /mcp)`);
|
|
97
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Servidor MCP de Facturador Pulsando — transporte STDIO (uso local:
|
|
4
|
+
* Claude Desktop / Claude Code / Cursor). Para el transporte remoto ver http.ts.
|
|
5
|
+
*
|
|
6
|
+
* Config por variables de entorno:
|
|
7
|
+
* PULSANDO_API_KEY (obligatoria) sk_test_... o sk_live_...
|
|
8
|
+
* PULSANDO_BASE_URL (opcional) default api.facturador.pulsandotech.cl
|
|
9
|
+
*/
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { buildServer, DEFAULT_BASE_URL } from "./server.js";
|
|
12
|
+
const API_KEY = process.env.PULSANDO_API_KEY;
|
|
13
|
+
const BASE_URL = (process.env.PULSANDO_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
14
|
+
if (!API_KEY) {
|
|
15
|
+
console.error("[facturador-mcp] Falta PULSANDO_API_KEY (sk_test_... o sk_live_...).");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const server = buildServer(API_KEY, BASE_URL);
|
|
19
|
+
const transport = new StdioServerTransport();
|
|
20
|
+
await server.connect(transport);
|
|
21
|
+
console.error("[facturador-mcp] servidor MCP listo (stdio).");
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
const clients = new Map();
|
|
3
|
+
const codes = new Map();
|
|
4
|
+
const tokens = new Map();
|
|
5
|
+
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
6
|
+
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
const rand = (n = 32) => b64url(randomBytes(n));
|
|
9
|
+
/** Verifica el PKCE S256: base64url(sha256(verifier)) === challenge. */
|
|
10
|
+
function verifyPkce(verifier, challenge) {
|
|
11
|
+
const hash = b64url(createHash("sha256").update(verifier).digest());
|
|
12
|
+
return hash === challenge;
|
|
13
|
+
}
|
|
14
|
+
/** Valida una API Key contra la API real (no-401 = llave válida). */
|
|
15
|
+
async function validateApiKey(apiKey, baseUrl) {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`${baseUrl}/dte?per_page=1`, {
|
|
18
|
+
headers: { "X-Api-Key": apiKey, Accept: "application/json" },
|
|
19
|
+
});
|
|
20
|
+
// 401 = llave inválida/inactiva. 200/403 (sin scope) = llave válida.
|
|
21
|
+
return res.status !== 401;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Devuelve la API Key mapeada a un access token válido, o null. */
|
|
28
|
+
export function resolveToken(accessToken) {
|
|
29
|
+
const t = tokens.get(accessToken);
|
|
30
|
+
if (!t)
|
|
31
|
+
return null;
|
|
32
|
+
if (Date.now() > t.expiresAt) {
|
|
33
|
+
tokens.delete(accessToken);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return t.apiKey;
|
|
37
|
+
}
|
|
38
|
+
/** URL pública del servidor (para issuer/endpoints del metadata). */
|
|
39
|
+
function publicBaseUrl(req) {
|
|
40
|
+
const envUrl = process.env.PUBLIC_URL;
|
|
41
|
+
if (envUrl)
|
|
42
|
+
return envUrl.replace(/\/$/, "");
|
|
43
|
+
const proto = req.headers["x-forwarded-proto"] || req.protocol;
|
|
44
|
+
return `${proto}://${req.get("host")}`;
|
|
45
|
+
}
|
|
46
|
+
function escapeHtml(s) {
|
|
47
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Monta los endpoints OAuth en la app Express. `apiBaseUrl` es la API de Pulsando.
|
|
51
|
+
*/
|
|
52
|
+
export function mountOAuth(app, apiBaseUrl) {
|
|
53
|
+
// ── Metadata: Protected Resource (RFC 9728) ──────────────────────────────
|
|
54
|
+
app.get("/.well-known/oauth-protected-resource", (req, res) => {
|
|
55
|
+
const base = publicBaseUrl(req);
|
|
56
|
+
res.json({
|
|
57
|
+
resource: `${base}/mcp`,
|
|
58
|
+
authorization_servers: [base],
|
|
59
|
+
bearer_methods_supported: ["header"],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
// ── Metadata: Authorization Server (RFC 8414) ────────────────────────────
|
|
63
|
+
app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
64
|
+
const base = publicBaseUrl(req);
|
|
65
|
+
res.json({
|
|
66
|
+
issuer: base,
|
|
67
|
+
authorization_endpoint: `${base}/authorize`,
|
|
68
|
+
token_endpoint: `${base}/token`,
|
|
69
|
+
registration_endpoint: `${base}/register`,
|
|
70
|
+
response_types_supported: ["code"],
|
|
71
|
+
grant_types_supported: ["authorization_code"],
|
|
72
|
+
code_challenge_methods_supported: ["S256"],
|
|
73
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
74
|
+
scopes_supported: ["mcp"],
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// ── Dynamic Client Registration (RFC 7591) ───────────────────────────────
|
|
78
|
+
app.post("/register", (req, res) => {
|
|
79
|
+
const redirectUris = Array.isArray(req.body?.redirect_uris)
|
|
80
|
+
? req.body.redirect_uris
|
|
81
|
+
: [];
|
|
82
|
+
const clientId = `mcp-${rand(12)}`;
|
|
83
|
+
clients.set(clientId, { client_id: clientId, redirect_uris: redirectUris });
|
|
84
|
+
res.status(201).json({
|
|
85
|
+
client_id: clientId,
|
|
86
|
+
redirect_uris: redirectUris,
|
|
87
|
+
token_endpoint_auth_method: "none",
|
|
88
|
+
grant_types: ["authorization_code"],
|
|
89
|
+
response_types: ["code"],
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ── /authorize: muestra la pantalla de consentimiento ────────────────────
|
|
93
|
+
app.get("/authorize", (req, res) => {
|
|
94
|
+
const { client_id, redirect_uri, state, code_challenge, code_challenge_method } = req.query;
|
|
95
|
+
if (!client_id || !redirect_uri || !code_challenge) {
|
|
96
|
+
res.status(400).send("Faltan parámetros OAuth (client_id, redirect_uri, code_challenge).");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if ((code_challenge_method ?? "S256") !== "S256") {
|
|
100
|
+
res.status(400).send("Solo se soporta code_challenge_method=S256.");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const html = `<!doctype html><html lang="es"><head><meta charset="utf-8">
|
|
104
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
105
|
+
<title>Conectar Facturador Pulsando</title>
|
|
106
|
+
<style>body{font-family:system-ui,sans-serif;background:#0b1220;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
|
107
|
+
.card{background:#111a2e;border:1px solid #1e293b;border-radius:16px;padding:32px;max-width:420px;width:90%}
|
|
108
|
+
h1{font-size:20px;margin:0 0 8px}p{color:#94a3b8;font-size:14px;line-height:1.5}
|
|
109
|
+
input{width:100%;box-sizing:border-box;padding:12px;margin:16px 0;border-radius:8px;border:1px solid #334155;background:#0b1220;color:#e2e8f0;font-family:monospace}
|
|
110
|
+
button{width:100%;padding:12px;border:0;border-radius:8px;background:#22c55e;color:#052e16;font-weight:700;font-size:15px;cursor:pointer}
|
|
111
|
+
.hint{font-size:12px;color:#64748b;margin-top:12px}</style></head>
|
|
112
|
+
<body><form class="card" method="POST" action="/authorize">
|
|
113
|
+
<h1>Conectar con Facturador Pulsando</h1>
|
|
114
|
+
<p>Autoriza a tu asistente de IA a emitir y consultar DTE en tu nombre. Pega tu <b>API Key</b> de Pulsando (usa <code>sk_test_</code> para probar).</p>
|
|
115
|
+
<input type="password" name="api_key" placeholder="sk_live_... o sk_test_..." autocomplete="off" required>
|
|
116
|
+
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
|
|
117
|
+
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
|
|
118
|
+
<input type="hidden" name="state" value="${escapeHtml(state ?? "")}">
|
|
119
|
+
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">
|
|
120
|
+
<button type="submit">Autorizar</button>
|
|
121
|
+
<p class="hint">La llave se guarda cifrada en nuestro servidor y tu asistente nunca la ve. Puedes revocarla desde el portal.</p>
|
|
122
|
+
</form></body></html>`;
|
|
123
|
+
res.type("html").send(html);
|
|
124
|
+
});
|
|
125
|
+
// ── /authorize (POST): valida la API Key y emite el code ─────────────────
|
|
126
|
+
app.post("/authorize", async (req, res) => {
|
|
127
|
+
const { api_key, client_id, redirect_uri, state, code_challenge } = req.body ?? {};
|
|
128
|
+
if (!api_key || !client_id || !redirect_uri || !code_challenge) {
|
|
129
|
+
res.status(400).send("Solicitud inválida.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const valid = await validateApiKey(String(api_key), apiBaseUrl);
|
|
133
|
+
if (!valid) {
|
|
134
|
+
res.status(401).type("html").send('<p style="font-family:system-ui">API Key inválida o inactiva. <a href="javascript:history.back()">Volver</a></p>');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const code = rand(24);
|
|
138
|
+
codes.set(code, {
|
|
139
|
+
apiKey: String(api_key),
|
|
140
|
+
clientId: String(client_id),
|
|
141
|
+
redirectUri: String(redirect_uri),
|
|
142
|
+
codeChallenge: String(code_challenge),
|
|
143
|
+
expiresAt: Date.now() + CODE_TTL_MS,
|
|
144
|
+
});
|
|
145
|
+
const sep = String(redirect_uri).includes("?") ? "&" : "?";
|
|
146
|
+
const url = `${redirect_uri}${sep}code=${encodeURIComponent(code)}${state ? `&state=${encodeURIComponent(String(state))}` : ""}`;
|
|
147
|
+
res.redirect(url);
|
|
148
|
+
});
|
|
149
|
+
// ── /token: intercambia code + PKCE por access_token ─────────────────────
|
|
150
|
+
app.post("/token", (req, res) => {
|
|
151
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body ?? {};
|
|
152
|
+
if (grant_type !== "authorization_code") {
|
|
153
|
+
res.status(400).json({ error: "unsupported_grant_type" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const entry = code ? codes.get(String(code)) : undefined;
|
|
157
|
+
if (!entry || Date.now() > entry.expiresAt) {
|
|
158
|
+
codes.delete(String(code));
|
|
159
|
+
res.status(400).json({ error: "invalid_grant" });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (redirect_uri && redirect_uri !== entry.redirectUri) {
|
|
163
|
+
res.status(400).json({ error: "invalid_grant", error_description: "redirect_uri mismatch" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!code_verifier || !verifyPkce(String(code_verifier), entry.codeChallenge)) {
|
|
167
|
+
res.status(400).json({ error: "invalid_grant", error_description: "PKCE verification failed" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
codes.delete(String(code)); // un solo uso
|
|
171
|
+
const accessToken = rand(32);
|
|
172
|
+
tokens.set(accessToken, { apiKey: entry.apiKey, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
173
|
+
res.json({
|
|
174
|
+
access_token: accessToken,
|
|
175
|
+
token_type: "Bearer",
|
|
176
|
+
expires_in: Math.floor(TOKEN_TTL_MS / 1000),
|
|
177
|
+
scope: "mcp",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fábrica del servidor MCP de Facturador Pulsando.
|
|
3
|
+
*
|
|
4
|
+
* Define las herramientas una sola vez y las comparte entre el transporte stdio
|
|
5
|
+
* (uso local, index.ts) y el transporte HTTP remoto (http.ts). La API Key se
|
|
6
|
+
* inyecta por parámetro: en stdio viene del env; en remoto, del header
|
|
7
|
+
* Authorization de cada request (cada usuario usa su propia llave → el paywall
|
|
8
|
+
* y los scopes se respetan por tenant).
|
|
9
|
+
*/
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
export const DEFAULT_BASE_URL = "https://api.facturador.pulsandotech.cl/api/public/v1";
|
|
14
|
+
/** Cliente HTTP mínimo contra la API con el header X-Api-Key. */
|
|
15
|
+
function makeApi(apiKey, baseUrl) {
|
|
16
|
+
return async function api(method, path, opts = {}) {
|
|
17
|
+
const headers = {
|
|
18
|
+
"X-Api-Key": apiKey,
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
// Marca el tráfico como originado por el MCP (agentes de IA) para que la
|
|
21
|
+
// plataforma lo atribuya en métricas/monetización (source=mcp).
|
|
22
|
+
"X-Client": "facturador-mcp/0.2.0",
|
|
23
|
+
"User-Agent": "facturador-mcp/0.2.0",
|
|
24
|
+
};
|
|
25
|
+
if (opts.body !== undefined)
|
|
26
|
+
headers["Content-Type"] = "application/json";
|
|
27
|
+
if (opts.idempotencyKey)
|
|
28
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
29
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers,
|
|
32
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
33
|
+
});
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let pretty = text;
|
|
36
|
+
try {
|
|
37
|
+
pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* respuesta no-JSON: se devuelve tal cual */
|
|
41
|
+
}
|
|
42
|
+
const replayed = res.headers.get("Idempotent-Replayed") === "true";
|
|
43
|
+
return `HTTP ${res.status}${replayed ? " (idempotent-replayed)" : ""}\n${pretty}`;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
47
|
+
const fail = (text) => ({
|
|
48
|
+
content: [{ type: "text", text }],
|
|
49
|
+
isError: true,
|
|
50
|
+
});
|
|
51
|
+
// ── Esquemas reutilizables ──────────────────────────────────────────────────
|
|
52
|
+
const receptor = z
|
|
53
|
+
.object({
|
|
54
|
+
rut: z.string().describe("RUT sin puntos ni DV, ej '12345678'"),
|
|
55
|
+
dv: z.string().describe("Dígito verificador, ej '5' o 'K'"),
|
|
56
|
+
razon_social: z.string(),
|
|
57
|
+
giro: z.string().optional(),
|
|
58
|
+
direccion: z.string().optional(),
|
|
59
|
+
comuna: z.string().optional(),
|
|
60
|
+
ciudad: z.string().optional(),
|
|
61
|
+
email: z.string().optional(),
|
|
62
|
+
})
|
|
63
|
+
.describe("Receptor. Opcional en boletas (39/41) y NC/ND de boleta.");
|
|
64
|
+
const item = z.object({
|
|
65
|
+
nombre: z.string().describe("Descripción, máx 80 caracteres"),
|
|
66
|
+
cantidad: z.number().optional(),
|
|
67
|
+
precio_unitario: z.number().int().optional(),
|
|
68
|
+
monto_item: z.number().int().describe("Monto de la línea, entero CLP"),
|
|
69
|
+
});
|
|
70
|
+
const totales = z.object({
|
|
71
|
+
monto_neto: z.number().int().optional(),
|
|
72
|
+
monto_iva: z.number().int().optional(),
|
|
73
|
+
monto_exento: z.number().int().optional(),
|
|
74
|
+
monto_total: z.number().int(),
|
|
75
|
+
tasa_iva: z.number().optional(),
|
|
76
|
+
});
|
|
77
|
+
const referencia = z.object({
|
|
78
|
+
tipo_dte_ref: z.number().int(),
|
|
79
|
+
folio_ref: z.number().int(),
|
|
80
|
+
fecha_ref: z.string().describe("YYYY-MM-DD"),
|
|
81
|
+
razon_ref: z.string().optional(),
|
|
82
|
+
});
|
|
83
|
+
const qs = (args) => new URLSearchParams(Object.entries(args)
|
|
84
|
+
.filter(([, v]) => v !== undefined)
|
|
85
|
+
.map(([k, v]) => [k, String(v)])).toString();
|
|
86
|
+
/**
|
|
87
|
+
* Construye un McpServer con todas las herramientas, usando la API Key dada.
|
|
88
|
+
*/
|
|
89
|
+
export function buildServer(apiKey, baseUrl = DEFAULT_BASE_URL) {
|
|
90
|
+
const api = makeApi(apiKey, baseUrl);
|
|
91
|
+
const server = new McpServer({ name: "facturador-pulsando", version: "0.2.0" });
|
|
92
|
+
server.tool("emitir_dte", "Emite un DTE al SII de Chile (factura 33/34, boleta 39/41, guía 52, ND 56, NC 61). " +
|
|
93
|
+
"NO envíes el emisor (se toma de la empresa dueña de la API Key). Asíncrono: " +
|
|
94
|
+
"responde folio y estado 'pendiente'. Idempotency-Key automática.", {
|
|
95
|
+
tipo_dte: z.number().int(),
|
|
96
|
+
fecha_emision: z.string().describe("YYYY-MM-DD, no futura (hora Chile)"),
|
|
97
|
+
receptor: receptor.optional(),
|
|
98
|
+
items: z.array(item).min(1).describe("Facturas/NC: montos NETOS. Boletas: CON IVA."),
|
|
99
|
+
totales,
|
|
100
|
+
referencias: z.array(referencia).optional().describe("Obligatorio en NC/ND 56/61"),
|
|
101
|
+
ind_traslado: z.number().int().optional().describe("Solo guía 52"),
|
|
102
|
+
incluir_pdf: z.boolean().optional(),
|
|
103
|
+
}, async (args) => {
|
|
104
|
+
try {
|
|
105
|
+
const { incluir_pdf, ...body } = args;
|
|
106
|
+
const path = "/dte" + (incluir_pdf ? "?incluir=pdf" : "");
|
|
107
|
+
return ok(await api("POST", path, { body, idempotencyKey: randomUUID() }));
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
return fail(`Error al emitir: ${e.message}`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
server.tool("consultar_dte", "Consulta detalle y estado SII de un DTE por id (incluye detalle_sii si fue rechazado).", { id: z.number().int() }, async ({ id }) => {
|
|
114
|
+
try {
|
|
115
|
+
return ok(await api("GET", `/dte/${id}`));
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return fail(`Error: ${e.message}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
server.tool("listar_dtes", "Lista/filtra DTE emitidos (reportes). Ej: facturas rechazadas del mes.", {
|
|
122
|
+
q: z.string().optional(),
|
|
123
|
+
tipo_dte: z.string().optional().describe("Un tipo o CSV '33,34'"),
|
|
124
|
+
estado_local: z.string().optional(),
|
|
125
|
+
desde: z.string().optional(),
|
|
126
|
+
hasta: z.string().optional(),
|
|
127
|
+
per_page: z.number().int().optional(),
|
|
128
|
+
page: z.number().int().optional(),
|
|
129
|
+
}, async (args) => {
|
|
130
|
+
try {
|
|
131
|
+
const q = qs(args);
|
|
132
|
+
return ok(await api("GET", `/dte${q ? `?${q}` : ""}`));
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return fail(`Error: ${e.message}`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
server.tool("consultar_contribuyente", "Padrón SII por RUT (razón social + giros). Para autocompletar/validar el receptor.", { rut: z.string().describe("Con o sin puntos y DV, ej '76354771-K'") }, async ({ rut }) => {
|
|
139
|
+
try {
|
|
140
|
+
return ok(await api("GET", `/contribuyentes/${encodeURIComponent(rut)}`));
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
return fail(`Error: ${e.message}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
server.tool("estado_folios", "Estado de folios (CAF) por tipo: próximo folio, disponibles y alertas.", {}, async () => {
|
|
147
|
+
try {
|
|
148
|
+
return ok(await api("GET", "/caf/status"));
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return fail(`Error: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
server.tool("documentos_recibidos", "Lista documentos que otros te emitieron (intercambio). Requiere scope recepcion:read.", {
|
|
155
|
+
pendientes: z.boolean().optional(),
|
|
156
|
+
rut_emisor: z.string().optional(),
|
|
157
|
+
per_page: z.number().int().optional(),
|
|
158
|
+
}, async (args) => {
|
|
159
|
+
try {
|
|
160
|
+
const q = qs(args);
|
|
161
|
+
return ok(await api("GET", `/dte/recibidos${q ? `?${q}` : ""}`));
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
return fail(`Error: ${e.message}`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
server.tool("sincronizar_rcv", "Encola la sincronización del Registro de Compras y Ventas (RCV) del SII para un " +
|
|
168
|
+
"periodo (YYYYMM) y operación (compra/venta/ambas). Requiere scope rcv:read.", {
|
|
169
|
+
periodo: z.string().describe("YYYYMM, ej '202607'"),
|
|
170
|
+
operacion: z.enum(["compra", "venta", "ambas"]).optional(),
|
|
171
|
+
}, async (args) => {
|
|
172
|
+
try {
|
|
173
|
+
return ok(await api("POST", "/rcv/sincronizar", { body: args }));
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
return fail(`Error: ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
server.tool("listar_rcv", "Lee el RCV ya sincronizado (compras/ventas del SII). Filtros: periodo, operacion, tipo_dte.", {
|
|
180
|
+
periodo: z.string().optional().describe("YYYYMM"),
|
|
181
|
+
operacion: z.enum(["compra", "venta"]).optional(),
|
|
182
|
+
tipo_dte: z.number().int().optional(),
|
|
183
|
+
per_page: z.number().int().optional(),
|
|
184
|
+
}, async (args) => {
|
|
185
|
+
try {
|
|
186
|
+
const q = qs(args);
|
|
187
|
+
return ok(await api("GET", `/rcv${q ? `?${q}` : ""}`));
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
return fail(`Error: ${e.message}`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return server;
|
|
194
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@facturador-mcp-sii.cl/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Servidor MCP para la API de facturación electrónica SII Chile de Facturador Pulsando. Conecta un agente de IA (Claude, etc.) a la emisión y consulta de DTE.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"sii",
|
|
9
|
+
"chile",
|
|
10
|
+
"factura-electronica",
|
|
11
|
+
"dte",
|
|
12
|
+
"boleta-electronica",
|
|
13
|
+
"facturacion",
|
|
14
|
+
"claude"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Pulsando Tech",
|
|
18
|
+
"homepage": "https://www.pulsandotech.cl",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"facturador-mcp": "dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"start": "node dist/index.js",
|
|
32
|
+
"start:http": "node dist/http.js",
|
|
33
|
+
"dev": "tsc --watch",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
38
|
+
"express": "^4.19.2",
|
|
39
|
+
"zod": "^3.23.8"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"typescript": "^5.5.0",
|
|
43
|
+
"@types/node": "^20.14.0",
|
|
44
|
+
"@types/express": "^4.17.21"
|
|
45
|
+
}
|
|
46
|
+
}
|