Framework-agnostic Model Context Protocol plumbing for Laravel. Contracts, multi-turn tool-calling orchestrator, stdio + HTTP transports, audit trail, RBAC hooks. Powers AskMyDocs and reusable in any Laravel AI app.
Every Padosoft package ships a .claude/ folder with curated skills, rules,
and commands so Claude Code, Cursor, Copilot, and any other LLM agent
can drive the package productively from day one. The pack documents the
extension points the framework guarantees as stable (contracts,
events, config keys) and the ones that are intentionally private —
so AI agents stop guessing and start composing.
# Drop into a fresh consumer project
composer require padosoft/askmydocs-mcp-pack
cp -r vendor/padosoft/askmydocs-mcp-pack/.claude ./
# Then ask Claude Code: "wire the orchestrator into my host bridge"- Why this package?
- Features at a glance
- Screenshots — Admin Web Panel
- Comparison vs alternatives
- Installation
- Quick start (3 minutes)
- Architecture
- Core concepts
- Configuration reference
- Recipes
- Extension points
- Testing
- Compatibility matrix
- Roadmap
- Changelog
- License
MCP is the open standard Anthropic released in November 2024 for LLM ⇆ tool wire-format interoperability. Within months it was adopted by Cursor, Claude Desktop, VS Code, Cline, Continue, Sourcegraph Cody, OpenAI's Realtime API, and a long tail of editor extensions and agentic frameworks.
What MCP gives you:
- A JSON-RPC 2.0 contract for
initialize,tools/list,tools/call,resources/*,prompts/*. - Transport choice — stdio (child process) for desktop tools and HTTP/SSE for cloud gateways.
- A growing public catalog of servers (filesystem, GitHub, Slack, Postgres, Notion, Sentry, …) you can plug into any client.
What MCP does not give you (and what this pack adds):
- A multi-turn tool-calling loop that drives the model → tools → model → tools cycle with budget caps and audit trail.
- RBAC / tenant gates in front of every tool invocation.
- An opinionated audit table with SHA-256 input/output hashes, duration, status, and error excerpts — the kind of trail an EU AI-Act audit will ask for.
- Provider-agnostic integration: the orchestrator does NOT bind
the OpenAI / Anthropic / Gemini SDK. You implement a 30-line
McpHostBridgeContractagainst your existing chat manager, and the pack handles the rest.
That is exactly the shape AskMyDocs needed for v7.0. We extracted it so the next Laravel AI app does not have to reinvent it.
| ✓ | Capability |
|---|---|
| 🔌 | Two transports out of the box — stdio (Symfony Process) and http (Guzzle via Laravel HTTP client). |
| 🧠 | Multi-turn tool-calling orchestrator — bounded by max_iterations, with deterministic message reshaping. |
| 🛡️ | Tenant-scoped tool catalog — forTenant($id) filters by tenant; cross-tenant leakage is structurally impossible. |
| 🚦 | Per-call RBAC — McpToolAuthorizerContract gates every tool BEFORE it appears in the catalog. |
| 🧾 | Hash-only audit trail — mcp_tool_call_audit rows store SHA-256 of input + result, NOT raw payloads. |
| 🔄 | Cached handshakes — initialize + tools/list are cached per (tenant, server) for 5 min by default. |
| 🧪 | Stub-friendly tests — McpClient::useTransportResolver() swaps the transport with a one-line closure. |
| 📦 | Zero-AI-SDK lock-in — pluggable host bridge; works with any provider. |
| 📊 | Production telemetry — every tool call carries duration_ms, status, and error excerpt. |
| 🧰 | Artisan diagnostics — php artisan mcp-pack:ping walks the registry and prints a per-server status table. |
The companion SPA (padosoft/askmydocs-mcp-pack-admin,
post-v7.0 cycle) consumes the v1.4 admin REST routes shipped here. Light + dark
themes ship out of the box; every action is keyboard-reachable and
audit-logged.
The landing surface — fleet health, breaker open-count, recent audit volume per server, and at-a-glance latency percentiles.
| Light | Dark |
|---|---|
![]() |
![]() |
Servers list filters by tenant + transport + status. Drill in for the
handshake-cached tool catalog, the most recent audit slice, and the
breaker state for every (server, tool) pair — read from
CircuitBreaker::peekState() so the dashboard NEVER consumes the
half-open probe slot just by polling.
Paginated audit query over the configurable mcp-pack.audit_model,
tenant-scoped by default. Filters: server_id, tool_name,
status, date range. Click a row for the SHA-256 input/output
hashes + redacted error excerpt + duration.
Prompt catalog (JSON-RPC prompts/list / prompts/get), an
embedded API playground for verifying the routes against the host's
auth middleware, and a settings surface mirroring the
mcp-pack.* config block.
| Feature | askmydocs-mcp-pack | laravel/mcp (Laravel first-party) |
php-llm/mcp-sdk (community) |
Roll-your-own |
|---|---|---|---|---|
| MCP client support (call upstream) | ✅ stdio + http | ❌ server-only | ✅ stdio + http | DIY |
| MCP server support (expose tools) | ✅ | ✅ | DIY | |
| Multi-turn tool-calling loop | ✅ | ❌ | ❌ | DIY (~300 LOC) |
| Provider-agnostic host bridge | ✅ | n/a | ❌ (OpenAI-coupled) | DIY |
| Tenant boundary built-in | ✅ forTenant($id) |
❌ | ❌ | DIY |
| Audit trail with hashes | ✅ migration shipped | ❌ | ❌ | DIY (~ADR + migration) |
| RBAC hook before tool exposure | ✅ contract | ❌ | ❌ | DIY (middleware?) |
| Cached handshake | ✅ 5min default | ❌ | ❌ | DIY |
| Stub transport for tests | ✅ one-line closure | ❌ | partial | DIY |
| .claude/ vibe-coding pack | ✅ | ❌ | ❌ | DIY |
| License | MIT | MIT | MIT | n/a |
laravel/mcp is excellent for exposing Laravel as an MCP server —
this pack and laravel/mcp are complementary, not competing. Use both
together: laravel/mcp to expose your KB as a server, and this pack
to consume other MCP servers from inside your chat flow.
composer require padosoft/askmydocs-mcp-packPublish config + migrations (optional — both load automatically):
php artisan vendor:publish --tag=mcp-pack-config
php artisan vendor:publish --tag=mcp-pack-migrations
php artisan migrateService provider is auto-discovered via composer.json::extra.laravel.providers.
This is the one piece you must write — about 30 lines of glue against your existing chat provider:
<?php
namespace App\Mcp;
use App\Ai\AiManager;
use Padosoft\AskMyDocsMcpPack\Contracts\McpHostBridgeContract;
use Padosoft\AskMyDocsMcpPack\Support\HostChatResponse;
use Padosoft\AskMyDocsMcpPack\Support\HostChatTurn;
final class MyHostBridge implements McpHostBridgeContract
{
public function __construct(private readonly AiManager $ai) {}
public function chat(HostChatTurn $turn): HostChatResponse
{
// Translate $turn->tools into your provider's tool-calling shape.
$providerTools = array_map(
fn($tool) => [
'type' => 'function',
'function' => [
'name' => $tool->name(),
'description' => $tool->description(),
'parameters' => $tool->schema(),
],
],
$turn->tools,
);
$response = $this->ai->chatWithHistory('', $turn->messages, [
'tools' => $providerTools,
'tool_choice' => 'auto',
] + $turn->extras);
return new HostChatResponse(
content: $response->content,
toolCalls: $this->normalizeToolCalls($response->toolCalls),
provider: $response->provider,
model: $response->model,
);
}
public function supportsToolCalling(): bool
{
return in_array($this->ai->provider()->name(), ['openai', 'openrouter'], true);
}
private function normalizeToolCalls(?array $raw): array
{
return collect($raw ?? [])->map(fn($c) => [
'id' => $c['id'],
'name' => $c['function']['name'] ?? $c['name'],
'arguments' => is_string($c['function']['arguments'] ?? '')
? json_decode($c['function']['arguments'], true) ?? []
: ($c['arguments'] ?? []),
])->all();
}
}use Padosoft\AskMyDocsMcpPack\Contracts\McpHostBridgeContract;
$this->app->singleton(McpHostBridgeContract::class, App\Mcp\MyHostBridge::class);In-memory (config-driven):
use Padosoft\AskMyDocsMcpPack\Contracts\McpServerRegistryContract;
use Padosoft\AskMyDocsMcpPack\Defaults\InMemoryMcpServer;
use Padosoft\AskMyDocsMcpPack\Defaults\InMemoryMcpServerRegistry;
$this->app->singleton(McpServerRegistryContract::class, function () {
$registry = new InMemoryMcpServerRegistry();
$registry->add(new InMemoryMcpServer(
id: 'fs',
name: 'Filesystem',
transport: 'stdio',
tenantId: null, // platform-global
transportConfig: [
'command' => 'npx',
'args' => ['-y', '@modelcontextprotocol/server-filesystem', '/data'],
'timeout_ms' => 10_000,
],
allowedTools: ['read_file', 'list_directory'],
));
return $registry;
});Or back it with your own Eloquent model — see Recipes.
use Padosoft\AskMyDocsMcpPack\Services\McpToolCallingService;
use Padosoft\AskMyDocsMcpPack\Support\HostMessage;
$svc = app(McpToolCallingService::class);
$response = $svc->chatWithTools(
messages: [
HostMessage::system('You are AskMyDocs. Use tools when grounded retrieval helps.'),
HostMessage::user('What did the deploy runbook change in March?'),
],
tenantId: 'acme',
actor: auth()->id(),
context: ['conversation_id' => 42, 'message_id' => 7],
);
return $response->content;Behind the scenes the orchestrator:
- Looks up enabled servers for tenant
acme. - Handshakes each one (cached for 5 min).
- Filters tools through your
McpToolAuthorizerContract. - Hands the catalog to your
MyHostBridge::chat(). - If the model asks for a tool: invokes it through
tools/call, appends the result, and loops back. - Audits every call into
mcp_tool_call_audit.
php artisan mcp-pack:ping --tenant=acme+-----+------------+-----------+--------+--------+---------+-------+
| id | name | transport | tenant | status | #tools | error |
+-----+------------+-----------+--------+--------+---------+-------+
| fs | Filesystem | stdio | acme | ok | 11 | |
+-----+------------+-----------+--------+--------+---------+-------+
┌────────────────────────────────────────────────────────────────────────┐
│ Your controller │
│ └─► McpToolCallingService::chatWithTools() │
│ │ │
│ ├─► McpServerRegistryContract::forTenant($id) ─── tenant gate │
│ ├─► McpHandshakeService::refresh() ─── cached │
│ ├─► McpToolAuthorizerContract::authorize() ─── RBAC gate │
│ │ │
│ ├─► McpHostBridgeContract::chat($turn) ─── YOUR CODE │
│ │ (turn = messages + tool catalog + tenant + extras) │
│ │ │
│ ├─► (loop until model returns no tool_calls or budget hits) │
│ │ │
│ ├─► ToolInvoker::invoke() │
│ │ └─► McpClient::callTool() ── JSON-RPC tools/call ────┐ │
│ │ └─► McpToolCallAudit::create() ─── audit row │
│ │ │
│ └─► returns HostChatResponse(content, toolCalls, usage) │
└────────────────────────────────────────────────────────────────────────┘
│
┌────────────────▼────────────────┐
│ Upstream MCP server │
│ (stdio child process OR │
│ HTTP gateway) │
└─────────────────────────────────┘
Five contracts, three transports, one orchestrator. The blast-radius of swapping any one of them is bounded by the contract.
A single MCP endpoint your host can talk to. Carries:
id()— stable identifier scoped per tenant.transport()—stdioorhttp.tenantId()—null= platform-global; a string = scoped to that tenant.transportConfig()—{command, args, cwd, env}for stdio;{endpoint, headers, timeout_ms}for http.allowedTools()— empty array = "all tools the server advertises"; otherwise a per-server allow-list.
Default implementation: InMemoryMcpServer. Production: subclass it
on top of your Eloquent model.
Per-tenant catalog of McpServerContract entries. The orchestrator
always asks forTenant($id) — never a global all(). Cross-tenant
leakage is structurally impossible.
Default implementation: InMemoryMcpServerRegistry. Production: back
it with your own McpServer Eloquent model.
The 30-line wrapper around your existing chat manager (OpenAI, Anthropic, OpenRouter, Gemini, …). The pack does NOT bind any AI SDK — this is what keeps it provider-agnostic.
RBAC gate. Called BEFORE the tool appears in the model's catalog, so denied tools never even reach the prompt token budget.
Default implementation: NullMcpToolAuthorizer (allows everything —
fine for prototypes, MUST be replaced in production).
The unit of work. Most consumers don't implement this directly —
RemoteMcpTool is built from the upstream server's tools/list
response and used by the orchestrator. You implement it only if you
need to expose an in-process tool with no upstream MCP server
(uncommon).
config/mcp-pack.php:
| Key | Env var | Default | Purpose |
|---|---|---|---|
tool_calling.enabled |
MCP_PACK_TOOL_CALLING_ENABLED |
false |
Master kill-switch. |
tool_calling.max_iterations |
MCP_PACK_TOOL_CALLING_MAX_ITERATIONS |
3 |
Hard cap on tool-calling loops per chat turn. |
tool_calling.default_tool_choice |
MCP_PACK_TOOL_CHOICE |
auto |
OpenAI-style hint passed to the bridge. |
handshake.ttl_seconds |
MCP_PACK_HANDSHAKE_TTL |
300 |
How long to cache initialize + tools/list. |
audit_model |
MCP_PACK_AUDIT_MODEL |
McpToolCallAudit::class |
Override to subclass the audit model. |
final class EloquentMcpServerRegistry implements McpServerRegistryContract
{
public function forTenant(?string $tenantId): array
{
return McpServer::query()
->where('tenant_id', $tenantId)
->where('enabled', true)
->get()
->map(fn($m) => new InMemoryMcpServer(
id: (string) $m->id,
name: $m->name,
transport: $m->transport,
tenantId: $m->tenant_id,
transportConfig: $m->transport_config ?? [],
allowedTools: $m->allowed_tools ?? [],
))
->all();
}
public function find(string $id): ?McpServerContract
{
$m = McpServer::query()->where('id', $id)->where('enabled', true)->first();
return $m === null ? null : new InMemoryMcpServer(/* same wrap as above */);
}
}
$this->app->singleton(McpServerRegistryContract::class, EloquentMcpServerRegistry::class);final class SpatieMcpToolAuthorizer implements McpToolAuthorizerContract
{
public function authorize(mixed $actor, ?string $tenantId, McpToolContract $tool): bool
{
if (! $actor instanceof User) { return false; }
if (! $actor->hasAnyRole(['admin', 'super-admin'])) { return false; }
$permission = $tool->isReadOnly() ? "mcp.{$tool->name()}.read" : "mcp.{$tool->name()}.write";
return $actor->hasPermissionTo($permission);
}
}new InMemoryMcpServer(
id: 'github',
name: 'GitHub MCP',
transport: 'stdio',
tenantId: 'acme',
transportConfig: [
'command' => 'npx',
'args' => ['-y', '@modelcontextprotocol/server-github'],
'env' => ['GITHUB_PERSONAL_ACCESS_TOKEN' => env('GH_PAT')],
'timeout_ms' => 15_000,
],
allowedTools: ['search_repositories', 'get_file_contents'],
);new InMemoryMcpServer(
id: 'cloud-kb',
name: 'Cloud KB Gateway',
transport: 'http',
tenantId: 'acme',
transportConfig: [
'endpoint' => 'https://mcp.example.com/rpc',
'headers' => ['Authorization' => 'Bearer ' . env('MCP_TOKEN')],
'timeout_ms' => 5_000,
'health_path' => '/healthz',
],
);If your host already owns a mcp_tool_call_audit table that pre-dates
this pack, the package migration is a no-op
(Schema::hasTable('mcp_tool_call_audit') guards both up() and
down()). To keep the host's operator-forensics columns (raw redacted
payload, user-FK, error blob, …) AND satisfy the package contract,
ship ONE additive host migration and one model subclass:
// database/migrations/...add_input_hash_and_actor_to_mcp_tool_call_audit.php
Schema::table('mcp_tool_call_audit', function (Blueprint $table) {
$table->char('input_hash', 64)->nullable()->after('input_json_redacted');
$table->string('actor', 100)->nullable()->after('user_id');
// (also relax any NOT NULL host columns the package does not write)
});
// Backfill existing rows so SHA-256 lookups match pre- and post-pack:
DB::table('mcp_tool_call_audit')
->whereNull('input_hash')
->orderBy('id')
->chunkById(500, function ($rows) {
foreach ($rows as $row) {
$payload = is_array($row->input_json_redacted)
? json_encode($row->input_json_redacted, JSON_UNESCAPED_UNICODE)
: $row->input_json_redacted;
DB::table('mcp_tool_call_audit')
->where('id', $row->id)
->update(['input_hash' => hash('sha256', (string) $payload)]);
}
});// app/Models/McpToolCallAudit.php — subclass + bridging hook
class McpToolCallAudit extends \Padosoft\AskMyDocsMcpPack\Models\McpToolCallAudit
{
protected $table = 'mcp_tool_call_audit';
protected $fillable = [
// package contract
'tenant_id', 'actor', 'mcp_server_id', 'tool_name',
'input_hash', 'result_hash', 'duration_ms', 'status', 'error_excerpt',
// host-legacy columns kept for admin SPA
'user_id', 'input_json_redacted', 'error_json',
];
protected static function booted(): void
{
static::creating(function (self $row) {
// Bridge actor↔user_id so legacy joins still work.
if ($row->user_id === null && is_string($row->actor) && ctype_digit($row->actor)) {
$row->user_id = (int) $row->actor;
}
if (($row->actor === null || $row->actor === '') && $row->user_id !== null) {
$row->actor = (string) $row->user_id;
}
});
}
}// config/mcp-pack.php — point the package at the host subclass
return ['audit_model' => \App\Models\McpToolCallAudit::class];Now every package ToolInvoker::audit() row fills BOTH schemas; legacy
host writes continue to work; the host's existing admin UI and
operator-forensics queries keep rendering the same way they always did.
Drop in the circuit breaker + retry budget so a flaky MCP server doesn't pin every worker on a long timeout:
# .env — opt in, both layers are independent
MCP_PACK_CB_ENABLED=true
MCP_PACK_CB_FAILURE_THRESHOLD=5
MCP_PACK_CB_RECOVERY_SECONDS=30
MCP_PACK_RETRY_ENABLED=true
MCP_PACK_RETRY_MAX_ATTEMPTS=3
MCP_PACK_RETRY_BUCKET_SIZE=20
MCP_PACK_RETRY_BUCKET_WINDOW_SECONDS=60
MCP_PACK_RETRY_BASE_BACKOFF_MS=200
MCP_PACK_RETRY_MAX_BACKOFF_MS=5000// app/Providers/AppServiceProvider.php — wire alerting to the events
use Illuminate\Support\Facades\Event;
use Padosoft\AskMyDocsMcpPack\Resilience\Events\CircuitOpened;
use Padosoft\AskMyDocsMcpPack\Resilience\Events\RetryExhausted;
Event::listen(CircuitOpened::class, function (CircuitOpened $e): void {
// Page on-call: a server's tool is failing fast.
});
Event::listen(RetryExhausted::class, function (RetryExhausted $e): void {
// Dashboard tile: which (tenant, server) is burning its budget.
});ToolInvoker automatically routes through the mediator when either
knob is enabled; consumers don't change a line of code. The breaker
state is per (server_id, tool_name); the budget is per
(tenant_id, server_id) so cross-tenant isolation (R30) holds even
under load.
| Hook | Default | Override when… |
|---|---|---|
McpHostBridgeContract |
NullMcpHostBridge (throws) |
Always — wire your provider stack. |
McpServerRegistryContract |
InMemoryMcpServerRegistry |
You want DB-backed admin UI for server CRUD. |
McpToolAuthorizerContract |
NullMcpToolAuthorizer (allow-all) |
Always in production — wire RBAC + tenant policy. |
McpToolCallingService |
Bound via SP | Subclass for custom logging / retry / circuit-breaker logic. |
McpHandshakeService |
Bound via SP | Subclass to persist handshakes in a DB column. |
McpToolCallAudit |
Built-in model | Subclass + override mcp-pack.audit_model config. |
McpClient::useTransportResolver() |
null (uses transport from server) |
In tests — swap to a stub transport. |
The pack ships its own PHPUnit + Orchestra Testbench setup. To run its tests:
composer install
vendor/bin/phpunitTo test your own host using the pack's stubs:
use Padosoft\AskMyDocsMcpPack\Services\McpClient;
use Padosoft\AskMyDocsMcpPack\Tests\Support\StubMcpTransport;
$transport = (new StubMcpTransport())
->scriptInitialize()
->scriptListTools([['name' => 'kb_search', 'description' => '...', 'inputSchema' => []]])
->scriptToolCall('kb_search', ['hits' => [['title' => 'Doc A']]]);
McpClient::useTransportResolver(fn() => $transport);
// drive your chat flow — every JSON-RPC call hits the stub.End-to-end Playwright coverage in AskMyDocs exercises:
- chat UI with MCP tools enabled → tool-call summary card renders
- admin SPA
/admin/mcp-servers→ server CRUD +pingaction - audit log shows tool calls with status, duration, server name
| PHP | Laravel | Status |
|---|---|---|
| 8.3 | 11.x | ✅ tested in CI |
| 8.3 | 12.x | ✅ tested in CI |
| 8.3 | 13.x | ✅ tested in CI |
| 8.4 | 11.x | ✅ tested in CI |
| 8.4 | 12.x | ✅ tested in CI |
| 8.4 | 13.x | ✅ tested in CI |
| 8.5 | 13.x | ✅ tested in CI |
| Version | Status | Highlights |
|---|---|---|
| v1.0.0 | ✅ shipped 2026-05-15 | Contracts + orchestrator + stdio/http transports + audit + ping. |
| v1.0.1 | ✅ shipped 2026-05-15 | Defensive up()/down() guards on the audit-table migration so the package coexists with a host-owned mcp_tool_call_audit. Recipe 5 walks the coexistence pattern. |
| v1.1.0 | ✅ shipped 2026-05-15 | SseJsonRpcTransport for remote HTTP+SSE gateways; JSON-RPC resources/list + resources/read; JSON-RPC prompts/list + prompts/get. |
| v1.2.0 | ✅ shipped 2026-05-15 | First-class server-side — same package exposes a Laravel app AS an MCP server (stdio long-lived runner + HTTP route + JsonRpcRequestHandler dispatching initialize / tools/list / tools/call / resources/* / prompts/* to a host-supplied catalog + auth + RBAC). |
| v1.3.0 | ✅ shipped 2026-05-15 | Per-tool circuit breaker (closed / open / half_open with TTL recovery) + adaptive retry budget (token-bucket per (tenant, server) with exponential backoff capped at maxBackoffMs) + 5 telemetry events. Opt-in, default OFF. |
| v1.4.0 | ✅ shipped 2026-05-15 | Admin REST backend — read-mostly routes at /api/admin/mcp-pack/{servers,audit,circuit-breaker} registered by the package SP behind MCP_PACK_ADMIN_ENABLED=true. Middleware-driven auth (host wires Sanctum + RBAC). NO React/Vue code — this is the backend the separate padosoft/askmydocs-mcp-pack-admin SPA consumes. CRUD writes deferred to v1.5.0 with a writable registry contract. |
| ─ | ─ | ─ |
| post-v7.0 cycle | 📅 separate package | padosoft/askmydocs-mcp-pack-admin — standalone React SPA companion. Same pattern as padosoft/laravel-flow-admin / padosoft/laravel-pii-redactor-admin. Cross-mountable under /admin/mcp/ in any Laravel host that depends on this package + v1.4. Ships in its own repo with its own R36 cycle once AskMyDocs's v7.0/W6 host integration is green. |
The v1.1 → v1.4 cycle ships before the AskMyDocs host adopts the package. Consumers willing to ride v1.0 today are welcome to do so — the public API surface is stable and won't break before v2 — but AskMyDocs's own host integration is intentionally deferred to land over the complete v1.4 feature set (orchestrator + transports + server-side + circuit breaker + admin REST routes) in a single integration cycle rather than four partial passes. See lopadova/AskMyDocs roadmap for the host-side milestones (v7.0/W2 → W6).
- Initial release extracted from AskMyDocs v6.x.
McpToolContract,McpServerContract,McpServerRegistryContract,McpToolAuthorizerContract,McpHostBridgeContract.HttpJsonRpcTransport+StdioJsonRpcTransport.McpToolCallingServicemulti-turn loop with budget cap.McpHandshakeServicewith cached handshakes.ToolInvokerwith SHA-256 audit trail.mcp_tool_call_auditmigration.mcp-pack:pingArtisan diagnostic.NullMcpHostBridge+NullMcpToolAuthorizer+InMemoryMcpServerRegistrydefaults.
MIT © Padosoft. See LICENSE.












