1.0 stable for the consent-link profile — MCP binding profile, registry and forwarding. Other profiles and the core objects are draft. What is stable

PHP library — connect a PHP site in an hour

On this page
  1. 1. Install (5 minutes)
  2. 2. Answer three paths (15 minutes)
  3. 3. Plug in your mailer and your search (30 minutes)
  4. 4. Get listed (10 minutes)
  5. 5. Optional: recognise the forwarder
  6. 6. Test calls
  7. Tests

An Isto Intents intent server for any PHP site: PHP 8.1 or newer, no dependencies beyond PDO and JSON. Download it (ZIP). On WordPress, use the plugin instead — it is this library with a settings page.

It implements the consent-link profile exactly as the registry check and the forwarder expect it:

What it deliberately does not do: decide which of your listings fit a wish. That is your search. The library hands you each confirmed wish and delivers what your search returns.

1. Install (5 minutes)#

Unpack the ZIP anywhere outside your web root and require its autoloader:

require '/path/to/php-holder/autoload.php';

With Composer, add the unpacked folder as a path repository and require istointents/holder; the namespace is IstoIntents\Holder\ (PSR-4, src/).

2. Answer three paths (15 minutes)#

examples/index.php is a complete holder in thirty lines. In your own front controller:

use IstoIntents\Holder\{Config, FrontController, Holder, Keywords, NativeMailer};
use IstoIntents\Holder\Storage\{PdoDb, Storage};

$config = new Config('www.your-portal.pt', 'Your Portal', 'mailto:info@your-portal.pt');
$config->operatorCountry = 'PT';
$config->countries  = ['PT'];
$config->categories = ['vehicle'];          // or ['property'], or both
$config->languages  = ['pt', 'en'];
$config->currencies = ['EUR'];
$config->consentText = 'I want to receive e-mails with listings that match this wish …'; // the text of your other alerts

$storage = new Storage(new PdoDb(new PDO('mysql:host=localhost;dbname=portal;charset=utf8mb4', $user, $pass)));
$storage->install();                        // CREATE TABLE IF NOT EXISTS: safe on every start

$holder = new Holder($config, $storage, new NativeMailer('no-reply@your-portal.pt'), new Keywords());
if ((new FrontController($holder, $config))->run()) {
    exit;                                   // it was one of ours
}
// … the rest of your site

FrontController answers, and nothing else:

path what
POST /mcp/intents the MCP server (Streamable HTTP, JSON answers)
GET/POST /intent/{confirm,verify,stop}/<token> the pages a person opens
GET /.well-known/isto-intents.json the holder descriptor

If your web server does not hand /.well-known/… to PHP, save the descriptor as a static file: json_encode($holder->descriptor()). Using a framework instead? Call $holder->handleMcp(), $holder->handlePage() and $holder->descriptorResponse() from your own routes; each returns a plain object you copy into your framework's response. A Page is a title and an HTML fragment with every value escaped, so it fits into your own layout.

Behind a proxy or CDN, give FrontController a function that reads the visitor's address from the proxy's header — but only if every request passes through that proxy, otherwise a caller can choose the address and escape the limits.

3. Plug in your mailer and your search (30 minutes)#

NativeMailer uses PHP's mail(). Most hosts deliver better through their SMTP service: implement Mailer::send($to, $subject, $text, $headers) with whatever you already use.

Your search implements one method:

use IstoIntents\Holder\{ActiveIntent, Listing, MatchFinder};

final class MyFinder implements MatchFinder
{
    public function find(ActiveIntent $intent): iterable
    {
        // $intent->statement, ->niceToHave, ->buyBy; ->hiddenLimitAmount / ->hiddenLimitCurrency
        // are for filtering only — never show them.
        foreach (my_search($intent->statement, maxPrice: $intent->hiddenLimitAmount) as $row) {
            yield new Listing("https://www.your-portal.pt/car/{$row['id']}", $row['title'], $row['price'], 'EUR');
        }
    }
}

Run it from cron, e.g. every hour:

$holder->runMatching(new MyFinder(), perIntent: 5);

Each listing is delivered once per wish: e-mailed with a stop link (and a List-Unsubscribe header) and shown to the assistant by intent_list_matches. Its url must be https on your host — a forwarder refuses anything else. Messages a person reads are English by default; pass your translations in Config::$messages (keys in Messages::DEFAULTS).

Keywords is a deliberately plain reading of a wish — its words, and a maximum price when the sentence names one — which tells the assistant what you will watch for. Replace it with your own Understanding when your search understands more.

4. Get listed (10 minutes)#

  1. Check that https://<your host>/.well-known/isto-intents.json answers. Config::$registryListing (on by default) asks to be listed.
  2. On istointents.org/join check your host and tick trial run: the registry calls your four tools once, as a forwarder would, and tells you whether you are ready.
  3. Submit: POST https://istointents.org/registry/v0/submit with { "host": "<your host>" }.
  4. To receive wishes from people whose AI assistant does not know you yet, set $config->forwarding = true; the descriptor then asks for forwarding under the forwarding terms.

5. Optional: recognise the forwarder#

The forwarder at https://istointents.com/mcp reaches you from one shared address, so it would hit your per-network limit. It signs every call with a key issued for your host alone. Ask for it through istointents.com and set $config->forwarderKey; verified calls then get their own limit. Without a key the server works the same.

6. Test calls#

A request with x-isto-test: 1 (the registry's trial run) is answered exactly like any other, but not counted in $holder->stats() and never handed to your search.

Tests#

composer install && vendor/bin/phpunit
ISTO_TEST_PDO="mysql:host=127.0.0.1;dbname=test" ISTO_TEST_USER=… ISTO_TEST_PASS=… vendor/bin/phpunit

22 tests: the exact interface, the sealed limit in both styles, the refusal of undeclared arguments, the double opt-in (a mail scanner confirms nothing, first come only, a failed confirmation e-mail leaves the link usable), escaping, delivery on your host only, withdrawal and expiry, the forwarder proof against a value computed by the Node holder, the limits (IPv6 per /64), test calls, restarts and the descriptor. They pass on PHP 8.1 and 8.2, with SQLite and with MariaDB 11.4.

Licence: GPL-2.0-or-later (LICENSE.txt), the same as the WordPress plugin built on it; the specification and the rest of the protocol's public part stay under CC BY 4.0.