portal-2 0.0.0-stage → 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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -2
  3. package/dist/api/errors.js +49 -0
  4. package/dist/api/json-schema.js +20 -0
  5. package/dist/cli/main.js +71 -0
  6. package/dist/cli/mcp.js +21 -0
  7. package/dist/cli/prompt.js +69 -0
  8. package/dist/cli/serve.js +73 -0
  9. package/dist/cli/update-check.js +56 -0
  10. package/dist/core/auth.js +83 -0
  11. package/dist/core/config.js +11 -0
  12. package/dist/core/context.js +56 -0
  13. package/dist/core/errors.js +32 -0
  14. package/dist/core/http.js +84 -0
  15. package/dist/core/index.js +4 -0
  16. package/dist/core/locale.js +3 -0
  17. package/dist/core/response.js +118 -0
  18. package/dist/core/schemas.js +36 -0
  19. package/dist/core/session.js +154 -0
  20. package/dist/core/throttle.js +19 -0
  21. package/dist/core/tls.js +63 -0
  22. package/dist/core/url.js +10 -0
  23. package/dist/env.js +10 -0
  24. package/dist/http/app.js +123 -0
  25. package/dist/http/docs.js +87 -0
  26. package/dist/http/errors.js +55 -0
  27. package/dist/http/input.js +43 -0
  28. package/dist/http/login.js +49 -0
  29. package/dist/mcp/env-session.js +38 -0
  30. package/dist/mcp/instructions.js +17 -0
  31. package/dist/mcp/results.js +73 -0
  32. package/dist/mcp/server.js +37 -0
  33. package/dist/mcp/tools.js +56 -0
  34. package/dist/openapi/generate.js +124 -0
  35. package/dist/operations/aulas-disciplina.js +298 -0
  36. package/dist/operations/aulas.js +123 -0
  37. package/dist/operations/avaliacoes-institucionais.js +106 -0
  38. package/dist/operations/calendario.js +188 -0
  39. package/dist/operations/conta.js +199 -0
  40. package/dist/operations/dates.js +33 -0
  41. package/dist/operations/define.js +31 -0
  42. package/dist/operations/disciplinas.js +108 -0
  43. package/dist/operations/etapas.js +155 -0
  44. package/dist/operations/faltas-aulas.js +136 -0
  45. package/dist/operations/faltas.js +127 -0
  46. package/dist/operations/grade-curricular.js +312 -0
  47. package/dist/operations/horario.js +175 -0
  48. package/dist/operations/index.js +40 -0
  49. package/dist/operations/materiais.js +239 -0
  50. package/dist/operations/notas.js +89 -0
  51. package/dist/operations/periodo.js +13 -0
  52. package/dist/operations/periodos.js +51 -0
  53. package/dist/operations/plano-aula.js +185 -0
  54. package/dist/operations/stages.js +35 -0
  55. package/dist/operations/subturmas.js +17 -0
  56. package/dist/operations/tags.js +9 -0
  57. package/dist/version.js +9 -0
  58. package/dist/web/assets/index-BfjkGOKT.js +13 -0
  59. package/dist/web/assets/index-Ij83t6cM.css +1 -0
  60. package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  61. package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  62. package/dist/web/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  63. package/dist/web/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  64. package/dist/web/assets/lexend-latin-400-normal-CHypAkT3.woff +0 -0
  65. package/dist/web/assets/lexend-latin-400-normal-CwRJVcUA.woff2 +0 -0
  66. package/dist/web/assets/lexend-latin-500-normal-D9x1QWTH.woff +0 -0
  67. package/dist/web/assets/lexend-latin-500-normal-Dl-X5nog.woff2 +0 -0
  68. package/dist/web/assets/lexend-latin-600-normal-DSBzZxpA.woff +0 -0
  69. package/dist/web/assets/lexend-latin-600-normal-DdvlkEkX.woff2 +0 -0
  70. package/dist/web/assets/study-breeze-gold-vazado-D6OTGJqc.webp +0 -0
  71. package/dist/web/assets/study-breeze-gold-vazado-still-CAGyOtjQ.png +0 -0
  72. package/dist/web/favicon.svg +1 -0
  73. package/dist/web/index.html +16 -0
  74. package/package.json +72 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arthur Adriano Mendes Machado
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,240 @@
1
- # Temporary Holding Version
1
+ # Portal 2
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A read-only API for your own data in the SENAI Portal do Aluno. It runs on your computer and talks to the portal with your account. No other server is involved, apart from npm: the one place it's installed and updated from.
4
+
5
+ Class project, not official, not affiliated with SENAI or FIEB. Scope and limits: [INTENT.md](INTENT.md). What the portal exposes: [ENDPOINTS.md](ENDPOINTS.md). The MCP server's design: [docs/MCP.md](docs/MCP.md).
6
+
7
+ ## Status
8
+
9
+ - [x] Core client: login, context cookie, re-login on expiry, rate limit (`src/core`)
10
+ - [x] Operations: `/eu`, `/eu/foto`, `/periodos`, `/disciplinas`, `/notas`, `/faltas`, `/faltas/aulas`, `/horario`, `/aulas`, `/disciplinas/{idTurmaDisc}/aulas`, `/disciplinas/{idTurmaDisc}/aulas/{idPlanoAula}`, `/disciplinas/{idTurmaDisc}/etapas`, `/calendario`, `/grade-curricular`, `/materiais`, `/disciplinas/{idTurmaDisc}/materiais`, `/materiais/{idMaterial}/arquivo`, `/avaliacoes-institucionais` (`src/operations`)
11
+ - [x] OpenAPI 3.1 document generated from the operations ([openapi.json](openapi.json))
12
+ - [x] Local server (`portal-2 serve`)
13
+ - [x] Docs page with "testar", built from `openapi.json` (`web/`)
14
+ - [x] MCP server for AI agents, over stdio (`portal-2 mcp`). Phases: [docs/MCP.md](docs/MCP.md)
15
+
16
+ ## Requirements
17
+
18
+ Node.js 22.19 or newer.
19
+
20
+ ## Use
21
+
22
+ ```sh
23
+ npx portal-2@latest serve
24
+ npx portal-2@latest serve --port 5000 --period 2026S1
25
+ ```
26
+
27
+ `npx` downloads the package the first time and always runs the newest release. To install the `portal-2` command instead:
28
+
29
+ ```sh
30
+ npm install -g portal-2 # run the same command with @latest to update
31
+ portal-2 serve
32
+ ```
33
+
34
+ When a newer release is on npm, `serve` says so at startup and prints the command that updates it. `NO_UPDATE_NOTIFIER=1` turns that check off.
35
+
36
+ It prints the docs address. The page works without an account: every endpoint, schema and example is there. To send requests with your data, click the user icon in the top right and log in with your portal user and password. "Lembrar de mim" keeps you logged in in this browser until the server restarts; without it, the login lasts for this tab only.
37
+
38
+ ```
39
+ Documentação: http://127.0.0.1:4321/docs/
40
+ ```
41
+
42
+ Endpoints that answer for one enrollment (`/disciplinas` and its lessons, `/notas`, `/faltas`, `/faltas/aulas`, `/horario`, `/aulas`) use the most recent one, or the one given with `--period`. Pass `periodo` with a code from `/periodos` to ask about another, for example `/notas?periodo=2026S1`.
43
+
44
+ From other tools, log in with `POST /sessao` (`{"usuario": "…", "senha": "…"}` as JSON) and send the returned token as `Authorization: Bearer <token>`. The docs page also has a "Copiar token" button. A new login, a logout (`DELETE /sessao`) or a restart invalidates the previous token.
45
+
46
+ With `PORTAL_ALUNO_USER` and `PORTAL_ALUNO_PASSWORD` set, the server logs in at startup instead and prints a link that already carries the token:
47
+
48
+ ```
49
+ ✓ Conectado. Matrícula do período 2026S2.
50
+
51
+ Documentação: http://127.0.0.1:4321/docs/#token=…
52
+ Token: …
53
+ ```
54
+
55
+ ## Use with AI agents (MCP)
56
+
57
+ `portal-2 mcp` is an [MCP](https://modelcontextprotocol.io) server: every operation of the API becomes a tool that Claude, Codex, Cursor, VS Code and other agents can call. The client starts it on your computer; it talks to the portal with your account, like `serve`.
58
+
59
+ It reads your portal user and password from `PORTAL_ALUNO_USER` and `PORTAL_ALUNO_PASSWORD`, which the client passes to it. It starts without contacting the portal and logs in on the first tool call, so a missing variable or a wrong password shows up in the chat as an error the agent can explain. After the portal refuses the password once, it stops trying until restarted, so it can't lock your account.
60
+
61
+ The setups below run `npx -y portal-2@latest mcp`, so every time the client starts the server it gets the newest release. Without `@latest`, npx may keep a cached old version.
62
+
63
+ **Claude Desktop.** Download `portal-2-<version>.mcpb` from the [latest release](https://github.com/ArthurAdrianoMM/portal-2/releases/latest) and open it. Claude Desktop asks for the user and password and keeps the password in its secure storage. To update, open the bundle from a newer release.
64
+
65
+ **Claude Code.**
66
+
67
+ ```sh
68
+ claude mcp add portal-2 -e PORTAL_ALUNO_USER=<usuário> -e PORTAL_ALUNO_PASSWORD=<senha> -- npx -y portal-2@latest mcp
69
+ ```
70
+
71
+ **VS Code** (`.vscode/mcp.json`). VS Code asks for the password and keeps it in its secure storage:
72
+
73
+ ```json
74
+ {
75
+ "inputs": [
76
+ { "type": "promptString", "id": "portal-usuario", "description": "Usuário do Portal do Aluno" },
77
+ { "type": "promptString", "id": "portal-senha", "description": "Senha do Portal do Aluno", "password": true }
78
+ ],
79
+ "servers": {
80
+ "portal-2": {
81
+ "type": "stdio",
82
+ "command": "npx",
83
+ "args": ["-y", "portal-2@latest", "mcp"],
84
+ "env": { "PORTAL_ALUNO_USER": "${input:portal-usuario}", "PORTAL_ALUNO_PASSWORD": "${input:portal-senha}" }
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ **Cursor, Gemini CLI and other clients** that take an `mcpServers` JSON:
91
+
92
+ ```json
93
+ {
94
+ "mcpServers": {
95
+ "portal-2": {
96
+ "command": "npx",
97
+ "args": ["-y", "portal-2@latest", "mcp"],
98
+ "env": { "PORTAL_ALUNO_USER": "<usuário>", "PORTAL_ALUNO_PASSWORD": "<senha>" }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ **Codex** (`~/.codex/config.toml`), also used by Codex inside the ChatGPT desktop app:
105
+
106
+ ```toml
107
+ [mcp_servers.portal-2]
108
+ command = "npx"
109
+ args = ["-y", "portal-2@latest", "mcp"]
110
+ env = { PORTAL_ALUNO_USER = "<usuário>", PORTAL_ALUNO_PASSWORD = "<senha>" }
111
+ ```
112
+
113
+ Add `"--period", "2026S1"` after `"mcp"` to answer about another enrollment by default.
114
+
115
+ Except for Claude Desktop and VS Code, the client keeps the password in its config file, in plain text. That file is yours, and nothing in this project writes it. ChatGPT on the web only connects to servers on the internet, so it can't use this one.
116
+
117
+ Every tool only reads. The tools say so to the client (`readOnlyHint`), so most clients run them without asking each time.
118
+
119
+ ## How the local server stays local
120
+
121
+ - It listens on `127.0.0.1` only.
122
+ - Every API route needs the token of the current login. `/docs`, `/openapi.json` and `/health` don't, and none of them carry personal data.
123
+ - The login route (`POST /sessao`) takes JSON only and refuses requests whose `Origin` is not this server, so another site open in the browser can't log you in or out of an account.
124
+ - It answers only when the `Host` header is `127.0.0.1:<port>` or `localhost:<port>`, which blocks DNS-rebinding attacks.
125
+ - It sends no CORS headers, so other sites open in your browser can't read its responses.
126
+ - The docs page is built into `dist/web` and served by this server, fonts included (no CDN). Its Content-Security-Policy allows scripts, styles and requests only to this server, with no inline code.
127
+ - The password goes from the page to this server and from here to the portal only. The server keeps it in memory, to log in again when the portal expires the session; it is never written to disk or logged.
128
+ - The page keeps the token in `sessionStorage` (this tab only), or in `localStorage` when "Lembrar de mim" is ticked. A token from the startup link reaches the page in the `#hash`, which browsers never send to servers, and is removed from the address bar.
129
+ - Files from the portal (materials, the profile photo) are sent as `attachment` with `Content-Security-Policy: default-src 'none'; sandbox`, so a file can never run as a page of this server, where the token lives. The docs page allows `blob:` images only to preview a photo it downloaded itself.
130
+ - The request log has method, path, status and time. No query strings, no bodies.
131
+ - At startup it asks the npm registry for the latest version of `portal-2`, to tell you about updates. The request carries nothing but the package name, and `NO_UPDATE_NOTIFIER=1` turns it off. `mcp` never makes it.
132
+
133
+ ## Development
134
+
135
+ ```sh
136
+ git clone https://github.com/ArthurAdrianoMM/portal-2.git && cd portal-2
137
+ npm install
138
+ npm run serve # builds the docs page, then serves from source; npm run serve -- --port 5000 for options
139
+ npm test # unit tests against an in-memory fake portal, no network
140
+ npm run typecheck
141
+ npm run openapi # regenerate openapi.json after changing an operation (a test fails if you forget)
142
+ npm run build # docs page to dist/web, server to dist/
143
+ npm run pack:mcpb # the Claude Desktop bundle, portal-2-<version>.mcpb
144
+ ```
145
+
146
+ Work on the docs page without logging in to the real portal:
147
+
148
+ ```sh
149
+ npm run dev:fake # the real server against the fake portal (made-up data), token "dev"; PORT=4399 npm run dev:fake for another port
150
+ npm run dev:web # in another terminal: the page with hot reload, at http://localhost:5173/docs/#token=dev
151
+ # the login screen accepts the fake account: aluno.teste / s3nha&com=símbolos
152
+ ```
153
+
154
+ Try the MCP tools without the real portal. The server logs in to the fake portal by itself, so the client needs no credentials:
155
+
156
+ ```sh
157
+ npm run -s dev:mcp:fake # MCP server on stdio, made-up data; -s keeps npm's banner off stdout
158
+ npm run -s dev:mcp:fake -- --senha-errada # the same, with a refused login
159
+ claude mcp add portal-2-fake -- npm --prefix "$PWD" run -s dev:mcp:fake
160
+ ```
161
+
162
+ CI runs the typecheck, the tests and the build on every pull request. Releases are a version tag; see [docs/RELEASING.md](docs/RELEASING.md).
163
+
164
+ In the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), connect to the command `npm run -s dev:mcp:fake` in this folder. Don't give the Inspector your real credentials: it saves the `env` values of stdio servers.
165
+
166
+ Run one operation against the real portal, without the server:
167
+
168
+ ```sh
169
+ npm run try # lists the operations
170
+ npm run try -- notas
171
+ npm run try -- listarDisciplinas '{"apenasEmCurso":true}'
172
+ npm run try -- faltas/aulas '{"de":"2026-08-01"}'
173
+ npm run try -- horario '{"incluirEncerrados":true}'
174
+ npm run try -- notas '{"periodo":"2026S1"}'
175
+ npm run try -- aulas '{"de":"2026-11-02","ate":"2026-11-06"}'
176
+ npm run try -- grade-curricular '{"situacao":"pendente"}'
177
+ npm run try -- listarAulasDaDisciplina '{"idTurmaDisc":48213}'
178
+ npm run try -- calendario '{"de":"2026-11-01","ate":"2026-11-30"}'
179
+ npm run try -- materiais
180
+ npm run try -- baixarMaterial '{"idMaterial":3150}' # prints the file's name, type and size, not the file
181
+ npm run try -- eu
182
+ npm run try -- avaliacoes-institucionais '{"objeto":"Curso"}'
183
+ ```
184
+
185
+ The output is your own data, so don't paste it anywhere public.
186
+
187
+ Map a portal endpoint before building on it. The probe prints only field names, types, string formats and counts, never values. `--values` lists the distinct values of the fields you name, for codes and enums only:
188
+
189
+ ```sh
190
+ npm run probe -- TOTVSEducacional/Periodos 'TOTVSEducacional/DisciplinasAlunoPeriodoLetivo?mostraApenasDiscEmCurso=false'
191
+ npm run probe -- TOTVSEducacional/Aluno/CalendarioAluno --values EVENTTYPE,LABEL
192
+ ```
193
+
194
+ ## Layout
195
+
196
+ ```
197
+ src/
198
+ core/ talks to the portal
199
+ auth.ts login steps (ListarAlias → JSON login → AutoLoginPortal → getcurrentuser)
200
+ context.ts enrollment context and the context cookie
201
+ session.ts PortalSession: in-memory session, re-login, getData()
202
+ http.ts fetch + cookie jar + throttle, no automatic redirects
203
+ tls.ts the intermediate certificate the portal leaves out of its chain
204
+ response.ts status handling, envelope, schema validation
205
+ errors.ts PortalError and its codes
206
+ api/ what the HTTP and MCP adapters share: error bodies, JSON Schema
207
+ operations/ one file per endpoint of this API: docs, schemas, handler
208
+ openapi/ builds the OpenAPI document from the operations
209
+ http/ the local server (Hono): routes, login and token, errors, docs page
210
+ mcp/ the MCP server: operations as tools, results, the stdio session (docs/MCP.md)
211
+ cli/ `portal-2` command (serve, mcp) and terminal prompts
212
+ env.ts the credentials in the environment
213
+ web/ the docs page (React + Vite). Reads /openapi.json; knows nothing else about the API
214
+ src/lib/ pure logic: spec model, Markdown subset, fields, samples, search (tested in test/web.test.ts)
215
+ src/components/
216
+ scripts/ dev runner (try.ts), shape probe (probe.ts), openapi.json writer, fake-portal servers (fake-serve.ts,
217
+ fake-mcp.ts, with demo-portal.ts), Claude Desktop bundle (pack-mcpb.ts)
218
+ docs/ design notes (MCP.md)
219
+ test/ Vitest, with an in-memory fake portal (test/support) and made-up portal answers (test/fixtures)
220
+ ```
221
+
222
+ ### Adding an endpoint
223
+
224
+ 1. Read the endpoint's section in ENDPOINTS.md. Only build on verified behavior.
225
+ 2. Add `src/operations/<name>.ts` with `defineOperation`: the upstream schema (only the fields you use, types as recorded), the output schema with `.describe()` on each field, a made-up `example`, callouts for anything surprising, and the handler.
226
+ 3. Register it in `src/operations/index.ts`.
227
+ 4. Add a test in `test/operations.test.ts` using `academicRoute` from the fake portal.
228
+ 5. Run `npm run openapi`. The route, the MCP tool, the docs page and the spec update from the definition. If the operation answers for one of the demo portal's routes, add it to `scripts/demo-portal.ts`, and its required arguments to `ARGS` in `test/mcp.test.ts`.
229
+
230
+ ## Rules the code follows
231
+
232
+ - Only GET requests to the API. Never `Logout` or `Contexto/Selecao`.
233
+ - The password is only sent to the configured portal origin, and the core never stores it. It's asked for through the credentials provider whenever a login is needed; the local server's provider holds it in memory for the life of the login.
234
+ - Cookies live in memory. Nothing is written to disk or logged.
235
+ - Error messages name fields and types, never values from the portal.
236
+ - If the portal refuses a request (403), the error is passed on. There is no retry another way.
237
+
238
+ ## License
239
+
240
+ [MIT](LICENSE) © 2026 Arthur Adriano Mendes Machado. Unofficial: not affiliated with SENAI, FIEB or TOTVS.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Errors as every adapter of this API reports them: a stable code, a message
3
+ * for people, and the details that help fix the call. The HTTP server adds a
4
+ * status; the MCP server returns them as tool errors. Messages never carry
5
+ * values from the portal.
6
+ */
7
+ import { z } from 'zod';
8
+ import { isPortalError } from '../core/index.js';
9
+ /** Errors any adapter can raise for itself, whatever the transport. */
10
+ export const COMMON_ERROR_CODES = {
11
+ parametro_invalido: 'Um parâmetro está faltando, sobrando ou com o tipo errado.',
12
+ erro_interno: 'Erro no servidor local. Veja o terminal onde ele está rodando.',
13
+ };
14
+ /**
15
+ * An error an adapter raises with a code of its own, such as a missing token
16
+ * or missing credentials. Each adapter documents its codes.
17
+ */
18
+ export class LocalError extends Error {
19
+ name = 'LocalError';
20
+ code;
21
+ constructor(code, message) {
22
+ super(message);
23
+ this.code = code;
24
+ }
25
+ }
26
+ /**
27
+ * Maps any thrown value to an error body. Portal errors keep their code and
28
+ * message; input errors list the fields; anything else is an internal error,
29
+ * whose details stay in the server's log. `LocalError`s are left to the
30
+ * adapter that raised them.
31
+ */
32
+ export function toErrorBody(error) {
33
+ if (isPortalError(error)) {
34
+ const body = { erro: error.code, mensagem: error.message };
35
+ if (error.portalMessages.length > 0) {
36
+ body.mensagensDoPortal = error.portalMessages.map((m) => ({ tipo: m.type ?? null, detalhe: m.detail ?? null }));
37
+ }
38
+ return { body, internal: false };
39
+ }
40
+ if (error instanceof z.ZodError) {
41
+ const body = { erro: 'parametro_invalido', mensagem: COMMON_ERROR_CODES.parametro_invalido };
42
+ body.detalhes = error.issues.map((issue) => ({
43
+ campo: issue.path.join('.') || (issue.code === 'unrecognized_keys' ? issue.keys.join(', ') : '(entrada)'),
44
+ mensagem: issue.message,
45
+ }));
46
+ return { body, internal: false };
47
+ }
48
+ return { body: { erro: 'erro_interno', mensagem: COMMON_ERROR_CODES.erro_interno }, internal: true };
49
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The JSON Schema of one side of a Zod schema, as the OpenAPI document and the
4
+ * MCP tools publish it. `io` picks the side: `input` for what callers send
5
+ * (defaults are optional), `output` for what they get back.
6
+ */
7
+ export function jsonSchema(schema, io) {
8
+ const { $schema: _, ...rest } = z.toJSONSchema(schema, {
9
+ io,
10
+ unrepresentable: 'throw',
11
+ // `.int()` adds the safe-integer range as bounds; they are noise for readers.
12
+ override: ({ jsonSchema }) => {
13
+ if (jsonSchema.minimum === Number.MIN_SAFE_INTEGER)
14
+ delete jsonSchema.minimum;
15
+ if (jsonSchema.maximum === Number.MAX_SAFE_INTEGER)
16
+ delete jsonSchema.maximum;
17
+ },
18
+ });
19
+ return rest;
20
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { isPortalError } from '../core/index.js';
4
+ import { VERSION } from '../version.js';
5
+ import { mcpCommand } from './mcp.js';
6
+ import { serveCommand } from './serve.js';
7
+ const HELP = `portal-2 ${VERSION}: seus dados do Portal do Aluno, numa API local.
8
+
9
+ Uso:
10
+ portal-2 serve [--port 4321] [--period 2026S2]
11
+ portal-2 mcp [--period 2026S2]
12
+
13
+ Comandos:
14
+ serve Serve a API e a documentação em 127.0.0.1.
15
+ mcp Servidor MCP no stdio, para agentes de IA. Quem o inicia é o cliente MCP.
16
+
17
+ Opções:
18
+ --port Porta local de serve (padrão 4321).
19
+ --period Período da matrícula a usar (CODPERLET). Padrão: a matrícula mais recente.
20
+
21
+ serve: entre com usuário e senha na documentação. Com PORTAL_ALUNO_USER e PORTAL_ALUNO_PASSWORD
22
+ definidos, o servidor já entra no portal ao iniciar.
23
+
24
+ mcp: lê PORTAL_ALUNO_USER e PORTAL_ALUNO_PASSWORD, definidos na configuração do cliente MCP,
25
+ e entra no portal na primeira chamada de ferramenta. Veja docs/MCP.md.
26
+ `;
27
+ function fail(message) {
28
+ console.error(message);
29
+ process.exit(2);
30
+ }
31
+ async function main(argv) {
32
+ const { values, positionals } = parseArgs({
33
+ args: argv,
34
+ allowPositionals: true,
35
+ options: {
36
+ port: { type: 'string' },
37
+ period: { type: 'string' },
38
+ help: { type: 'boolean', short: 'h' },
39
+ version: { type: 'boolean', short: 'v' },
40
+ },
41
+ });
42
+ if (values.version)
43
+ return void console.log(VERSION);
44
+ const [command, ...extra] = positionals;
45
+ if (values.help || command === undefined)
46
+ return void console.log(HELP);
47
+ if (extra.length > 0)
48
+ fail(`Argumento a mais: ${extra.join(' ')}\n\n${HELP}`);
49
+ switch (command) {
50
+ case 'serve': {
51
+ const port = Number(values.port ?? 4321);
52
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
53
+ fail('--port precisa ser um número entre 1 e 65535.');
54
+ return serveCommand({ port, ...(values.period === undefined ? {} : { period: values.period }) });
55
+ }
56
+ case 'mcp': {
57
+ if (values.port !== undefined)
58
+ fail('--port vale só para serve: o servidor MCP fala pelo stdio.');
59
+ return mcpCommand(values.period === undefined ? {} : { period: values.period });
60
+ }
61
+ default:
62
+ fail(`Comando desconhecido: ${command}\n\n${HELP}`);
63
+ }
64
+ }
65
+ main(process.argv.slice(2)).catch((error) => {
66
+ if (isPortalError(error))
67
+ console.error(`Erro [${error.code}]: ${error.message}`);
68
+ else
69
+ console.error(error instanceof Error ? error.message : error);
70
+ process.exit(1);
71
+ });
@@ -0,0 +1,21 @@
1
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
2
+ import { readEnvCredentials } from '../env.js';
3
+ import { envSession } from '../mcp/env-session.js';
4
+ import { createMcpServer } from '../mcp/server.js';
5
+ import { VERSION } from '../version.js';
6
+ /**
7
+ * `portal-2 mcp`: the MCP server on stdio, started by an MCP client. It
8
+ * starts without contacting the portal and logs in on the first tool call.
9
+ * stdout carries the protocol, so everything else goes to stderr, which the
10
+ * client keeps in its log for this server.
11
+ */
12
+ export function mcpCommand(options) {
13
+ const config = options.period === undefined ? {} : { codPerLet: options.period };
14
+ const log = (line) => console.error(line);
15
+ const session = envSession({ config });
16
+ serveStdio(() => createMcpServer({ session, log }), { onerror: (error) => log(`erro no transporte MCP: ${error.message}`) });
17
+ log(`portal-2 ${VERSION}: servidor MCP no stdio. A sessão do portal fica só na memória deste processo.`);
18
+ const read = readEnvCredentials();
19
+ if ('missing' in read)
20
+ log(`Aviso: falta ${read.missing.join(' e ')}. As ferramentas vão responder com o erro sem_credenciais.`);
21
+ }
@@ -0,0 +1,69 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ /** Reads one line. Fails if the input ends first (readline would otherwise never answer). */
3
+ export async function ask(question) {
4
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5
+ const closed = new Promise((_, reject) => {
6
+ rl.once('close', () => reject(new Error('A entrada terminou antes da resposta.')));
7
+ });
8
+ try {
9
+ return (await Promise.race([rl.question(question), closed])).trim();
10
+ }
11
+ finally {
12
+ rl.close();
13
+ }
14
+ }
15
+ /** Reads a line without echoing it. Needs an interactive terminal. */
16
+ export function askHidden(question) {
17
+ const { stdin, stdout } = process;
18
+ if (!stdin.isTTY) {
19
+ return Promise.reject(new Error('A senha precisa ser digitada num terminal interativo. Outra opção: PORTAL_ALUNO_PASSWORD.'));
20
+ }
21
+ stdout.write(question);
22
+ stdin.setRawMode(true);
23
+ stdin.setEncoding('utf8');
24
+ stdin.resume();
25
+ return new Promise((resolve, reject) => {
26
+ let value = '';
27
+ const finish = (error) => {
28
+ stdin.off('data', onData);
29
+ stdin.setRawMode(false);
30
+ stdin.pause();
31
+ stdout.write('\n');
32
+ if (error)
33
+ reject(error);
34
+ else
35
+ resolve(value);
36
+ };
37
+ const onData = (chunk) => {
38
+ for (const char of chunk) {
39
+ if (char === '\r' || char === '\n' || char === '\u0004')
40
+ return finish();
41
+ if (char === '\u0003')
42
+ return finish(new Error('Cancelado.'));
43
+ if (char === '\u007f' || char === '\b')
44
+ value = value.slice(0, -1);
45
+ else if (char >= ' ')
46
+ value += char;
47
+ }
48
+ };
49
+ stdin.on('data', onData);
50
+ });
51
+ }
52
+ /**
53
+ * Credentials from `PORTAL_ALUNO_USER` / `PORTAL_ALUNO_PASSWORD`, or asked in
54
+ * the terminal. Kept in this process's memory only, so the session can log in
55
+ * again after the portal expires it.
56
+ */
57
+ export function terminalCredentials(env = process.env) {
58
+ let cached;
59
+ return async () => {
60
+ if (!cached) {
61
+ const user = env.PORTAL_ALUNO_USER || (await ask('Usuário (RA): '));
62
+ const password = env.PORTAL_ALUNO_PASSWORD || (await askHidden('Senha: '));
63
+ if (!user || !password)
64
+ throw new Error('Usuário e senha são obrigatórios.');
65
+ cached = { user, password };
66
+ }
67
+ return cached;
68
+ };
69
+ }
@@ -0,0 +1,73 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { DEFAULT_CONFIG, PortalSession } from '../core/index.js';
3
+ import { createApp } from '../http/app.js';
4
+ import { createToken } from '../http/login.js';
5
+ import { PASSWORD_VAR, USER_VAR, readEnvCredentials } from '../env.js';
6
+ import { PACKAGE_NAME, VERSION } from '../version.js';
7
+ import { checkForUpdate, updateHint } from './update-check.js';
8
+ const HOSTNAME = '127.0.0.1';
9
+ function listen(app, port) {
10
+ return new Promise((resolve, reject) => {
11
+ const server = serve({ fetch: app.fetch, hostname: HOSTNAME, port }, () => resolve(server));
12
+ server.once('error', (error) => {
13
+ reject(error.code === 'EADDRINUSE'
14
+ ? new Error(`A porta ${port} já está em uso. Escolha outra com --port.`)
15
+ : error);
16
+ });
17
+ });
18
+ }
19
+ /** `PORTAL_ALUNO_USER` and `PORTAL_ALUNO_PASSWORD`, when both are set; `null` when neither is. */
20
+ export function envCredentials(env = process.env) {
21
+ const read = readEnvCredentials(env);
22
+ if ('credentials' in read)
23
+ return read.credentials;
24
+ if (read.missing.length === 2)
25
+ return null;
26
+ throw new Error(`Defina ${USER_VAR} e ${PASSWORD_VAR} juntos, ou nenhum dos dois.`);
27
+ }
28
+ /**
29
+ * `portal-2 serve`: serves the API and its docs on 127.0.0.1. Login happens
30
+ * in the docs page, or at startup when the credentials are in the environment.
31
+ */
32
+ export async function serveCommand(options) {
33
+ const config = options.period === undefined ? {} : { codPerLet: options.period };
34
+ // Kept in this process's memory only, so the session can log in again after the portal expires it.
35
+ const openSession = (credentials) => new PortalSession({ credentials: async () => credentials, config });
36
+ const fromEnv = envCredentials();
37
+ let initial;
38
+ if (fromEnv) {
39
+ console.log('Entrando no portal…');
40
+ const session = openSession(fromEnv);
41
+ const { context } = await session.login();
42
+ initial = { session, token: createToken(), period: context.codPerLet };
43
+ }
44
+ const app = createApp({
45
+ openSession,
46
+ ...(initial ? { initial } : {}),
47
+ portalUrl: config.baseUrl ?? DEFAULT_CONFIG.baseUrl,
48
+ port: options.port,
49
+ log: (line) => console.error(line),
50
+ });
51
+ const server = await listen(app, options.port);
52
+ const { port } = server.address();
53
+ const docs = `http://${HOSTNAME}:${port}/docs/`;
54
+ const lines = initial
55
+ ? [
56
+ `✓ Conectado. Matrícula do período ${initial.period}.`,
57
+ '',
58
+ `Documentação: ${docs}#token=${initial.token}`,
59
+ `Token: ${initial.token}`,
60
+ ]
61
+ : [`Documentação: ${docs}`, '', 'Entre com o usuário e a senha do portal pelo ícone no canto superior direito.'];
62
+ console.log([...lines, '', 'A sessão fica só na memória deste processo. Ctrl+C encerra e a apaga.'].join('\n'));
63
+ void checkForUpdate(PACKAGE_NAME, VERSION).then((latest) => {
64
+ if (latest)
65
+ console.log(`\nNova versão disponível: ${latest} (esta é a ${VERSION}). Atualize com: ${updateHint(PACKAGE_NAME)}`);
66
+ });
67
+ const stop = () => {
68
+ server.close();
69
+ process.exit(0);
70
+ };
71
+ process.once('SIGINT', stop);
72
+ process.once('SIGTERM', stop);
73
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tells `serve` when npm has a newer release. One request to the npm registry,
3
+ * made after the server is listening: it sends only the package name, never
4
+ * delays startup, and stays quiet when offline or on any error.
5
+ *
6
+ * `mcp` doesn't use it: its stdout carries the protocol, and clients that start
7
+ * it with `npx -y portal-2@latest` already get the newest release.
8
+ *
9
+ * Off when NO_UPDATE_NOTIFIER (the variable npm tools share) or CI is set.
10
+ */
11
+ const REGISTRY = 'https://registry.npmjs.org';
12
+ // Generous: it runs after startup, so a slow network delays nothing but the notice.
13
+ const TIMEOUT_MS = 5000;
14
+ function parse(version) {
15
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(version);
16
+ if (!match)
17
+ return null;
18
+ return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] !== undefined };
19
+ }
20
+ /** True when `candidate` is a release (no `-beta.0`) newer than `current`. */
21
+ export function isNewer(candidate, current) {
22
+ const next = parse(candidate);
23
+ const now = parse(current);
24
+ if (!next || !now || next.prerelease)
25
+ return false;
26
+ for (let i = 0; i < 3; i++) {
27
+ if (next.core[i] !== now.core[i])
28
+ return next.core[i] > now.core[i];
29
+ }
30
+ // Same x.y.z: the release is newer than its own pre-releases.
31
+ return now.prerelease;
32
+ }
33
+ /** The newer version on npm's `latest` tag, or `null` (up to date, turned off, or unknown). */
34
+ export async function checkForUpdate(name, current, options = {}) {
35
+ const env = options.env ?? process.env;
36
+ if (env.NO_UPDATE_NOTIFIER || env.CI)
37
+ return null;
38
+ try {
39
+ const response = await (options.fetch ?? fetch)(`${REGISTRY}/${name.replace('/', '%2f')}/latest`, {
40
+ headers: { accept: 'application/json' },
41
+ signal: AbortSignal.timeout(TIMEOUT_MS),
42
+ });
43
+ if (!response.ok)
44
+ return null;
45
+ const { version } = (await response.json());
46
+ return typeof version === 'string' && isNewer(version, current) ? version : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** What to run to update, depending on how this process was started. */
53
+ export function updateHint(name, env = process.env) {
54
+ // npx (npm exec) sets npm_command=exec; an outdated npx run means its cache is stale.
55
+ return env.npm_command === 'exec' ? `npx ${name}@latest serve` : `npm install -g ${name}@latest`;
56
+ }