librus-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ name: Lint and format check
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v4
18
+
19
+ - name: Set up Python
20
+ run: uv python install 3.12
21
+
22
+ - name: Install dependencies
23
+ run: uv pip install -e . --system
24
+
25
+ - name: Ruff check
26
+ run: uvx ruff check src/
27
+
28
+ - name: Ruff format check
29
+ run: uvx ruff format --check src/
30
+
31
+ - name: Bandit security check
32
+ run: uvx bandit -c pyproject.toml -r src/
@@ -0,0 +1,30 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ publish:
12
+ name: Build and publish to PyPI
13
+ runs-on: ubuntu-latest
14
+ environment:
15
+ name: pypi
16
+ url: https://pypi.org/p/librus-mcp
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v4
22
+
23
+ - name: Set up Python
24
+ run: uv python install 3.12
25
+
26
+ - name: Build package
27
+ run: uv build
28
+
29
+ - name: Publish to PyPI
30
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,68 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Distribution / packaging
8
+ build/
9
+ dist/
10
+ *.egg-info/
11
+ *.egg
12
+
13
+ # Virtual environments
14
+ .env
15
+ .venv
16
+ env/
17
+ venv/
18
+
19
+ # Unit test / coverage reports
20
+ htmlcov/
21
+ .tox/
22
+ .nox/
23
+ .coverage
24
+ .coverage.*
25
+ .pytest_cache/
26
+ coverage.xml
27
+
28
+ # mypy / Pyre type checkers
29
+ .mypy_cache/
30
+ .dmypy.json
31
+ dmypy.json
32
+ .pyre/
33
+
34
+ # pyenv
35
+ .python-version
36
+
37
+ # Secrets
38
+ secrets.json
39
+
40
+ # IDE settings
41
+ .idea/
42
+ .vscode/
43
+ *.swp
44
+ *.swo
45
+ *~
46
+
47
+ # OS files
48
+ .DS_Store
49
+ Thumbs.db
50
+
51
+ # AI coding assistants
52
+ .claude/ # Claude Code
53
+ .gemini/ # Gemini CLI
54
+ .codex/ # OpenAI Codex CLI
55
+ .cursor/ # Cursor
56
+ .windsurf/ # Windsurf
57
+ .aider* # Aider
58
+ .continue/ # Continue.dev
59
+ .codeium/ # Codeium
60
+ .copilot/ # GitHub Copilot
61
+ .augment/ # Augment Code
62
+ .junie/ # JetBrains Junie
63
+
64
+ # Linter / formatter cache
65
+ .ruff_cache/
66
+
67
+ # Logs
68
+ *.log
@@ -0,0 +1,51 @@
1
+ repos:
2
+ # General file checks
3
+ - repo: https://github.com/pre-commit/pre-commit-hooks
4
+ rev: v5.0.0
5
+ hooks:
6
+ # Prevent committing large files (default 500KB)
7
+ - id: check-added-large-files
8
+ args: ['--maxkb=500']
9
+ # Prevent committing files with credentials or secrets patterns
10
+ - id: detect-private-key
11
+ # Ensure JSON files are valid
12
+ - id: check-json
13
+ # Ensure TOML files are valid
14
+ - id: check-toml
15
+ # Ensure YAML files are valid
16
+ - id: check-yaml
17
+ # Prevent committing directly to main/master
18
+ - id: no-commit-to-branch
19
+ args: ['--branch', 'main', '--branch', 'master']
20
+ # Ensure files end with a newline
21
+ - id: end-of-file-fixer
22
+ # Remove trailing whitespace
23
+ - id: trailing-whitespace
24
+ # Catch common merge conflict markers
25
+ - id: check-merge-conflict
26
+ # Prevent adding symlinks that point nowhere
27
+ - id: check-symlinks
28
+
29
+ # Block secrets from being committed
30
+ - repo: https://github.com/Yelp/detect-secrets
31
+ rev: v1.5.0
32
+ hooks:
33
+ - id: detect-secrets
34
+ args: ['--baseline', '.secrets.baseline']
35
+ exclude: (uv\.lock|\.secrets\.baseline)$
36
+
37
+ # Python linting
38
+ - repo: https://github.com/astral-sh/ruff-pre-commit
39
+ rev: v0.11.11
40
+ hooks:
41
+ - id: ruff
42
+ args: ['--fix']
43
+ - id: ruff-format
44
+
45
+ # Security checks for Python code
46
+ - repo: https://github.com/PyCQA/bandit
47
+ rev: 1.8.3
48
+ hooks:
49
+ - id: bandit
50
+ args: ['-c', 'pyproject.toml']
51
+ additional_dependencies: ['bandit[toml]']
@@ -0,0 +1,143 @@
1
+ {
2
+ "version": "1.5.0",
3
+ "plugins_used": [
4
+ {
5
+ "name": "ArtifactoryDetector"
6
+ },
7
+ {
8
+ "name": "AWSKeyDetector"
9
+ },
10
+ {
11
+ "name": "AzureStorageKeyDetector"
12
+ },
13
+ {
14
+ "name": "Base64HighEntropyString",
15
+ "limit": 4.5
16
+ },
17
+ {
18
+ "name": "BasicAuthDetector"
19
+ },
20
+ {
21
+ "name": "CloudantDetector"
22
+ },
23
+ {
24
+ "name": "DiscordBotTokenDetector"
25
+ },
26
+ {
27
+ "name": "GitHubTokenDetector"
28
+ },
29
+ {
30
+ "name": "GitLabTokenDetector"
31
+ },
32
+ {
33
+ "name": "HexHighEntropyString",
34
+ "limit": 3.0
35
+ },
36
+ {
37
+ "name": "IbmCloudIamDetector"
38
+ },
39
+ {
40
+ "name": "IbmCosHmacDetector"
41
+ },
42
+ {
43
+ "name": "IPPublicDetector"
44
+ },
45
+ {
46
+ "name": "JwtTokenDetector"
47
+ },
48
+ {
49
+ "name": "KeywordDetector",
50
+ "keyword_exclude": ""
51
+ },
52
+ {
53
+ "name": "MailchimpDetector"
54
+ },
55
+ {
56
+ "name": "NpmDetector"
57
+ },
58
+ {
59
+ "name": "OpenAIDetector"
60
+ },
61
+ {
62
+ "name": "PrivateKeyDetector"
63
+ },
64
+ {
65
+ "name": "PypiTokenDetector"
66
+ },
67
+ {
68
+ "name": "SendGridDetector"
69
+ },
70
+ {
71
+ "name": "SlackDetector"
72
+ },
73
+ {
74
+ "name": "SoftlayerDetector"
75
+ },
76
+ {
77
+ "name": "SquareOAuthDetector"
78
+ },
79
+ {
80
+ "name": "StripeDetector"
81
+ },
82
+ {
83
+ "name": "TelegramBotTokenDetector"
84
+ },
85
+ {
86
+ "name": "TwilioKeyDetector"
87
+ }
88
+ ],
89
+ "filters_used": [
90
+ {
91
+ "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
92
+ },
93
+ {
94
+ "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
95
+ "min_level": 2
96
+ },
97
+ {
98
+ "path": "detect_secrets.filters.heuristic.is_indirect_reference"
99
+ },
100
+ {
101
+ "path": "detect_secrets.filters.heuristic.is_likely_id_string"
102
+ },
103
+ {
104
+ "path": "detect_secrets.filters.heuristic.is_lock_file"
105
+ },
106
+ {
107
+ "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
108
+ },
109
+ {
110
+ "path": "detect_secrets.filters.heuristic.is_potential_uuid"
111
+ },
112
+ {
113
+ "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
114
+ },
115
+ {
116
+ "path": "detect_secrets.filters.heuristic.is_sequential_string"
117
+ },
118
+ {
119
+ "path": "detect_secrets.filters.heuristic.is_swagger_file"
120
+ },
121
+ {
122
+ "path": "detect_secrets.filters.heuristic.is_templated_secret"
123
+ },
124
+ {
125
+ "path": "detect_secrets.filters.regex.should_exclude_file",
126
+ "pattern": [
127
+ "(uv\\.lock|\\.secrets\\.baseline|secrets\\.json)$"
128
+ ]
129
+ }
130
+ ],
131
+ "results": {
132
+ "secrets.json.template": [
133
+ {
134
+ "type": "Secret Keyword",
135
+ "filename": "secrets.json.template",
136
+ "hashed_secret": "cf109a70a9c73fd9a8061796eedc0d6a8a1d9add",
137
+ "is_verified": false,
138
+ "line_number": 6
139
+ }
140
+ ]
141
+ },
142
+ "generated_at": "2026-03-10T12:19:41Z"
143
+ }
@@ -0,0 +1,45 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a positive experience for everyone.
7
+
8
+ ## Our Standards
9
+
10
+ Examples of behavior that contributes to a positive environment:
11
+
12
+ - Using welcoming and inclusive language
13
+ - Being respectful of differing viewpoints and experiences
14
+ - Gracefully accepting constructive feedback
15
+ - Focusing on what is best for the community
16
+ - Showing empathy towards other community members
17
+
18
+ Examples of unacceptable behavior:
19
+
20
+ - Trolling, insulting or derogatory comments, and personal attacks
21
+ - Publishing others' private information without explicit permission
22
+ - Other conduct which could reasonably be considered inappropriate in a professional setting
23
+
24
+ ## Enforcement Responsibilities
25
+
26
+ Project maintainers are responsible for clarifying and enforcing standards of
27
+ acceptable behavior and will take appropriate and fair corrective action in
28
+ response to any behavior that they deem inappropriate or harmful.
29
+
30
+ ## Scope
31
+
32
+ This Code of Conduct applies within all project spaces, including issues,
33
+ pull requests, and discussions.
34
+
35
+ ## Enforcement
36
+
37
+ Instances of unacceptable behavior may be reported to the project maintainer
38
+ at [contact@datacraze.io](mailto:contact@datacraze.io). All complaints will
39
+ be reviewed and investigated promptly and fairly.
40
+
41
+ ## Attribution
42
+
43
+ This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/),
44
+ version 2.1, available at
45
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
@@ -0,0 +1,84 @@
1
+ # Contributing to librus-mcp
2
+
3
+ Thank you for your interest in contributing! This document provides guidelines to make the process smooth for everyone.
4
+
5
+ ## Getting Started
6
+
7
+ 1. Fork the repository
8
+ 2. Clone your fork and set up the development environment:
9
+ ```bash
10
+ git clone https://github.com/YOUR_USERNAME/librus-mcp.git
11
+ cd librus-mcp
12
+ uv venv && uv pip install -e .
13
+ ```
14
+ 3. Create a branch for your change:
15
+ ```bash
16
+ git checkout -b your-branch-name
17
+ ```
18
+
19
+ ## Development Workflow
20
+
21
+ ### Code Style
22
+
23
+ The key rules:
24
+
25
+ - **Safety first:** Validate inputs and outputs with assertions (~2 per function)
26
+ - **Split assertions:** `assert a; assert b` — never `assert a and b`
27
+ - **Functions <= 70 lines**
28
+ - **No abbreviations** in variable names
29
+ - **Comments explain "why"**, not "what"
30
+ - **Lines <= 100 columns**
31
+
32
+ ### Linting and Formatting
33
+
34
+ Before submitting, ensure your code passes:
35
+
36
+ ```bash
37
+ uvx ruff check src/
38
+ uvx ruff format src/
39
+ ```
40
+
41
+ ### Testing
42
+
43
+ Run the verification script to confirm basic connectivity works:
44
+
45
+ ```bash
46
+ python verify_connection.py
47
+ ```
48
+
49
+ When adding new functionality, ensure you test it manually against a real Librus account.
50
+
51
+ ## Submitting Changes
52
+
53
+ 1. Commit your changes with a clear, descriptive message
54
+ 2. Push to your fork
55
+ 3. Open a Pull Request against `main`
56
+ 4. Describe what your change does and why
57
+
58
+ ### What Makes a Good PR
59
+
60
+ - **Focused:** One logical change per PR
61
+ - **Documented:** Update README.md if you add/change tools
62
+ - **Linted:** All ruff checks pass with zero warnings
63
+ - **Tested:** You have verified the change works
64
+
65
+ ## Adding a New MCP Tool
66
+
67
+ See [SPEC.md](SPEC.md) for the step-by-step process of adding a new tool.
68
+
69
+ ## Reporting Bugs
70
+
71
+ Open a GitHub issue with:
72
+
73
+ - What you expected to happen
74
+ - What actually happened
75
+ - Steps to reproduce
76
+ - Your Python version and OS
77
+
78
+ ## Security Issues
79
+
80
+ **Do not open public issues for security vulnerabilities.** See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
81
+
82
+ ## Questions?
83
+
84
+ Open a GitHub issue or reach out at [contact@datacraze.io](mailto:contact@datacraze.io).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Krzysztof Bury
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.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: librus-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Librus Synergia — access grades, messages, attendance, homework, timetables, and announcements from AI assistants
5
+ Project-URL: Homepage, https://github.com/krzysztoofbury/librus-mcp
6
+ Project-URL: Repository, https://github.com/krzysztoofbury/librus-mcp
7
+ Project-URL: Issues, https://github.com/krzysztoofbury/librus-mcp/issues
8
+ Author-email: Krzysztof Bury <contact@datacraze.io>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude,gemini,gradebook,librus,llm,mcp,synergia
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: librus-apix>=0.1.0
22
+ Requires-Dist: mcp>=0.1.0
23
+ Requires-Dist: python-dotenv>=1.0.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Librus MCP Server
27
+
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
29
+ [![PyPI](https://img.shields.io/pypi/v/librus-mcp)](https://pypi.org/project/librus-mcp/)
30
+
31
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that provides AI assistants with access to the **Librus Synergia** electronic gradebook. It supports multiple student accounts simultaneously and exposes tools for grades, messages, attendance, homework, schedules, timetables, and announcements.
32
+
33
+ ## Acknowledgments
34
+
35
+ This project is built on top of the excellent [**librus-apix**](https://github.com/RustySnek/librus-apix) library by [**RustySnek**](https://github.com/RustySnek). Their work on reverse-engineering and maintaining a Python client for the Librus Synergia platform made this MCP server possible. If you find this project useful, please consider starring their repository as well.
36
+
37
+ ## Quick Start
38
+
39
+ ### 1. Add to your AI assistant
40
+
41
+ Pick your client. Credentials are passed directly via the `env` block — no files or cloning needed.
42
+
43
+ #### Claude Desktop
44
+
45
+ Add to your `claude_desktop_config.json`:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "librus": {
51
+ "command": "uvx",
52
+ "args": ["librus-mcp"],
53
+ "env": {
54
+ "LIBRUS_ACCOUNTS": "[{\"alias\":\"daughter\",\"username\":\"12345\",\"password\":\"...\"}]"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ #### Claude Code
62
+
63
+ Via the `/mcp` command or in `.claude/settings.json`:
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "librus": {
69
+ "command": "uvx",
70
+ "args": ["librus-mcp"],
71
+ "env": {
72
+ "LIBRUS_ACCOUNTS": "[{\"alias\":\"daughter\",\"username\":\"12345\",\"password\":\"...\"}]"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ #### Gemini CLI
80
+
81
+ Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project-level):
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "librus": {
87
+ "command": "uvx",
88
+ "args": ["librus-mcp"],
89
+ "env": {
90
+ "LIBRUS_ACCOUNTS": "[{\"alias\":\"daughter\",\"username\":\"12345\",\"password\":\"...\"}]"
91
+ }
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ #### OpenAI Codex CLI
98
+
99
+ Add to `~/.codex/config.toml` (global) or `.codex/config.toml` (project-level):
100
+
101
+ ```toml
102
+ [mcp_servers.librus]
103
+ command = "uvx"
104
+ args = ["librus-mcp"]
105
+
106
+ [mcp_servers.librus.env]
107
+ LIBRUS_ACCOUNTS = '[{"alias":"daughter","username":"12345","password":"..."}]'
108
+ ```
109
+
110
+ > **Note:** `uvx` automatically downloads and runs the package from PyPI — no cloning or virtual environments needed. You only need [uv](https://github.com/astral-sh/uv) installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`).
111
+
112
+ ### Providing credentials
113
+
114
+ Each account represents a **parent's login** to Librus Synergia for a specific child. In the Polish school system, parents receive separate Librus login credentials for each of their children. The `alias` is a friendly name you choose to identify which child's data you're accessing. The `username` and `password` are the parent portal credentials you use to log in at [synergia.librus.pl](https://synergia.librus.pl/).
115
+
116
+ There are three ways to provide credentials (checked in this order):
117
+
118
+ | Method | Best for | Example |
119
+ |--------|----------|---------|
120
+ | `LIBRUS_ACCOUNTS` env var | `uvx` users, CI | JSON array of account objects (see examples above) |
121
+ | `LIBRUS_CONFIG` env var | Custom file location | Path to your `secrets.json`, e.g. `~/.config/librus/secrets.json` |
122
+ | `secrets.json` in working dir | Local development | Create from `secrets.json.template` |
123
+
124
+ #### Multiple children
125
+
126
+ Add multiple accounts to the JSON array:
127
+
128
+ ```
129
+ [{"alias":"daughter","username":"12345","password":"..."},{"alias":"son","username":"67890","password":"..."}]
130
+ ```
131
+
132
+ #### Using a config file with `uvx`
133
+
134
+ If you prefer a file over inline JSON:
135
+
136
+ ```json
137
+ {
138
+ "mcpServers": {
139
+ "librus": {
140
+ "command": "uvx",
141
+ "args": ["librus-mcp"],
142
+ "env": {
143
+ "LIBRUS_CONFIG": "/Users/you/.config/librus/secrets.json"
144
+ }
145
+ }
146
+ }
147
+ }
148
+ ```
149
+
150
+ ### Alternative: Install from source
151
+
152
+ If you prefer to run from a local clone:
153
+
154
+ ```bash
155
+ git clone https://github.com/krzysztoofbury/librus-mcp.git
156
+ cd librus-mcp
157
+ uv venv && uv pip install -e .
158
+ cp secrets.json.template secrets.json # Then fill in credentials
159
+ ```
160
+
161
+ Then use the full path in your MCP config:
162
+
163
+ ```json
164
+ {
165
+ "mcpServers": {
166
+ "librus": {
167
+ "command": "/path/to/librus-mcp/.venv/bin/librus-mcp"
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ ## Available Tools
174
+
175
+ | Tool | Description |
176
+ |------|-------------|
177
+ | `list_students()` | List configured student aliases |
178
+ | `get_grades(student_alias)` | Get grades for a student |
179
+ | `get_messages(student_alias)` | Get received messages |
180
+ | `get_message_content(student_alias, message_id)` | Get the body of a specific message |
181
+ | `get_attendance(student_alias)` | Get attendance records |
182
+ | `get_homework(student_alias)` | Get homework for the next 2 weeks |
183
+ | `get_schedule(student_alias, year, month)` | Get calendar events/exams for a month |
184
+ | `get_timetable(student_alias)` | Get current week's timetable |
185
+ | `get_announcements(student_alias)` | Get school announcements |
186
+
187
+ ## Project Structure
188
+
189
+ ```
190
+ src/
191
+ server.py # MCP server with tool definitions and entry point
192
+ librus_client.py # Librus API client wrapper with caching and retry
193
+ config.py # Configuration loader (reads secrets.json)
194
+ patches.py # Runtime patches for librus-apix bugs
195
+ ```
196
+
197
+ ## Contributing
198
+
199
+ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
200
+
201
+ ## Security
202
+
203
+ To report vulnerabilities, see [SECURITY.md](SECURITY.md).
204
+
205
+ ## License
206
+
207
+ This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.