@quo-systems/dock 0.1.0 → 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 +9 -2
- package/beings/quo-dock.md +59 -22
- package/beings/setup.ts +3 -1
- package/cli/estate/Caddyfile +25 -0
- package/cli/estate/quo.service +36 -0
- package/cli/estate.ts +44 -0
- package/cli/quo.ts +13 -3
- package/dist/beings/setup.js +4 -1
- package/dist/cli/estate/Caddyfile +25 -0
- package/dist/cli/estate/quo.service +36 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +46 -0
- package/dist/cli/quo.js +13 -3
- package/dist/harbor/edge/exercise.js +3 -1
- package/dist/harbor/edge/platform.d.ts +28 -0
- package/dist/mcp/oauth.d.ts +2 -0
- package/dist/mcp/oauth.js +17 -7
- package/dist/mcp/pilot.d.ts +3 -4
- package/dist/mcp/pilot.js +19 -65
- package/dist/mcp/server.d.ts +10 -3
- package/dist/mcp/server.js +15 -9
- package/harbor/edge/exercise.ts +2 -1
- package/mcp/oauth.ts +19 -6
- package/mcp/pilot.ts +26 -65
- package/mcp/quo-mcp.md +17 -18
- package/mcp/server.ts +26 -12
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -8,10 +8,17 @@ built on the dock, on Quo.
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npm install @quo-systems/dock
|
|
11
|
-
npx quo
|
|
12
|
-
npx quo
|
|
11
|
+
npx quo estate my-estate --domain example.com
|
|
12
|
+
npx quo init --dir my-estate/droplet
|
|
13
|
+
npx quo serve --dir my-estate/droplet
|
|
13
14
|
```
|
|
14
15
|
|
|
16
|
+
`quo estate` writes an estate folder: one folder per harbor, the first a
|
|
17
|
+
droplet's quo directory with its routes under your domain, its unit and
|
|
18
|
+
its Caddyfile, and a package file that depends on the dock and nothing
|
|
19
|
+
else. Add your beings to `droplet/classes/`, and your placement to the
|
|
20
|
+
routes and agents files.
|
|
21
|
+
|
|
15
22
|
`beings/quo-dock.md` is the truth of this package and the trunk of its four
|
|
16
23
|
documents; read it first. It knows nothing the spec of Quo does not say,
|
|
17
24
|
and adds no word to harbor, ward or being.
|
package/beings/quo-dock.md
CHANGED
|
@@ -288,12 +288,13 @@ through the ask pointer or an avatar, the two doors everyone has.
|
|
|
288
288
|
```
|
|
289
289
|
quo init [--dir D] mint a seed, boot a harbor and one ward over a disk store, write both
|
|
290
290
|
quo serve [--dir D] [--dial URL] the daemon: harbor from disk, the unix socket, the routes' listeners, the agents, a socket held to URL
|
|
291
|
-
quo pilot [--dir D] [--via S] the owner pilot over stdio:
|
|
291
|
+
quo pilot [--dir D] [--via S] the owner pilot over stdio: the ward's describe as tools; as the root, or via a standing at another ward
|
|
292
292
|
quo side [--dir D] --as NAME an avatar over stdio for a local client; NAME is the client identity
|
|
293
293
|
quo run [--dir D] --as NAME --url URL --model NAME a human at a terminal talking to a model through an avatar; a line in, the final text out
|
|
294
294
|
quo census [--dir D] print the census once and exit
|
|
295
|
-
quo boot|invite|knock|remove the
|
|
295
|
+
quo boot|public|invite|knock|remove the five owner asks from a shell, for scripts and for agents under cron; --via S sends them to another ward
|
|
296
296
|
quo reach <pk> <url> a hint for the harbor's directory: that world's ward lives at that quo. route
|
|
297
|
+
quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, its unit and Caddyfile, a package on this dock
|
|
297
298
|
```
|
|
298
299
|
|
|
299
300
|
**One process owns the harbor.** `quo serve` is the only process that ever
|
|
@@ -502,13 +503,12 @@ above or sending us back to fix it:
|
|
|
502
503
|
command: no droplet, no OAuth.
|
|
503
504
|
5. Done. `quo pilot` in `packages/dock/mcp/pilot.ts`, proven in
|
|
504
505
|
`packages/dock/test/cli.test.ts` with the SDK's stdio client as a child
|
|
505
|
-
process: the
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
`quo pilot` as the command.
|
|
506
|
+
process: the model side of step 3 over the ward's owner asks, the
|
|
507
|
+
ward's describe as tools and no list of its own, each call an owner ask
|
|
508
|
+
on the daemon's root socket, and nothing logged. It is the root and not
|
|
509
|
+
an avatar: nothing is booted for it and no occupant is minted for it.
|
|
510
|
+
With no daemon it says so and exits. Claude Code as the owner is
|
|
511
|
+
`claude mcp add` with `quo pilot` as the command.
|
|
512
512
|
6. Done. `quo serve --http PORT`: the HTTP door on loopback, `/health`
|
|
513
513
|
naming the wards, routes mounting by path so a hostname on the proxy is
|
|
514
514
|
a path on the daemon; proven in `packages/dock/test/cli.test.ts`. The systemd
|
|
@@ -521,7 +521,10 @@ above or sending us back to fix it:
|
|
|
521
521
|
code grant, refresh with rotation, opaque tokens mapping a bearer to a
|
|
522
522
|
client identity, revoke by identity, and expiry. The conventions pinned
|
|
523
523
|
here: a pending request and a code live ten minutes, an access token
|
|
524
|
-
one hour, a refresh token thirty days; a
|
|
524
|
+
one hour, a refresh token thirty days; a registered client lives ten
|
|
525
|
+
minutes, and as long as its refresh token once the human allowed it,
|
|
526
|
+
since the register door is open to anyone and what nobody allowed must
|
|
527
|
+
not stay on disk; a code is spent the first time
|
|
525
528
|
it is offered, right or wrong; the client identity is chosen at the
|
|
526
529
|
allow page, not taken from the client's registration, because a client
|
|
527
530
|
like claude.ai registers a fresh client id on every connect and the
|
|
@@ -651,14 +654,23 @@ above or sending us back to fix it:
|
|
|
651
654
|
the library move waited for; the `ws` question answered in the harbor
|
|
652
655
|
document. `estates/lab/lab.md` keeps the record, one slip included.
|
|
653
656
|
|
|
654
|
-
19. The split
|
|
655
|
-
things moved to `estates/lab/`,
|
|
656
|
-
the
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
and the
|
|
660
|
-
|
|
661
|
-
|
|
657
|
+
19. The split: Done. The estate folder became the dock, the lab's own
|
|
658
|
+
things moved to `estates/lab/`, and the dock's package took its name.
|
|
659
|
+
Then the repository took the shape that says what ships by where it
|
|
660
|
+
lives: `packages/quo/` and `packages/dock/` are the two published
|
|
661
|
+
packages, `estates/` is private, and the root is nobody's package, the
|
|
662
|
+
workspaces and the gate. The lab is an estate folder of the shape this
|
|
663
|
+
document names, one folder per harbor, and `estates/lab/test/boot.test.ts`
|
|
664
|
+
is invariant 12 proven: a copy of the folder, the two packages installed
|
|
665
|
+
into it and nothing else, and the droplet boots. The lab redeployed
|
|
666
|
+
from it: the droplet from a clean export of HEAD with its unit copied
|
|
667
|
+
from the estate's folder, the edge from the estate's own config
|
|
668
|
+
resolving the dock's worker by name, the Mac's agent on the new path.
|
|
669
|
+
What it taught: a nested npm under `npm run` inherits the outer run's
|
|
670
|
+
local prefix by environment and installs into the repository instead
|
|
671
|
+
of the empty folder, so the stranger tests keep nothing of it; and
|
|
672
|
+
`npx` in a workspace runs at the package root, so an estate names its
|
|
673
|
+
edge config from its own folder.
|
|
662
674
|
20. The dock published: Done. `@quo-systems/dock` 0.1.0, the same shape
|
|
663
675
|
the library took, an emitted `dist/` with the `quo` command pointing
|
|
664
676
|
into it, the source and the four documents beside it, and
|
|
@@ -671,9 +683,34 @@ above or sending us back to fix it:
|
|
|
671
683
|
source, whichever is there, the one place the dock reads a file by
|
|
672
684
|
path at run time.
|
|
673
685
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
686
|
+
21. A second estate: Done, and live. `estates/gherghina/`,
|
|
687
|
+
staging like the lab and nothing more, one harbor on the shared droplet
|
|
688
|
+
`nervur-staging-0`, routes under `gherghina.quo.systems`, no agents and
|
|
689
|
+
no classes of its own yet, the same unit and Caddyfile shape as the
|
|
690
|
+
lab's. It is the first proof that the dock has two users: its folder
|
|
691
|
+
depends on the dock alone, and `estates/gherghina/test/boot.test.ts`
|
|
692
|
+
runs the one proof every estate here runs, `estates/proof.ts`, shared
|
|
693
|
+
between them and shipped nowhere. The three staging droplets are shared
|
|
694
|
+
by every estate in this repository, and none of them is production or
|
|
695
|
+
holds real data. Its droplet stood up the same day, its document says
|
|
696
|
+
how; what it taught went into both estates' units: the agent's folders
|
|
697
|
+
are optional to the daemon, since a harbor may have no agent.
|
|
698
|
+
|
|
699
|
+
22. The scaffold: Done. `quo estate <dir> --domain D` writes an estate
|
|
700
|
+
folder of the shape the two estates here agreed on: a package file
|
|
701
|
+
depending on this dock and nothing else, one document to fill in, and
|
|
702
|
+
`droplet/` as the first harbor, its routes under the domain, no
|
|
703
|
+
agents, no classes of its own, and that device's unit and Caddyfile
|
|
704
|
+
from templates the dock ships beside its command. The unit it writes
|
|
705
|
+
runs the installed dock from the estate's folder on the box, since an
|
|
706
|
+
adopter has no tree there; the two estates here run the tree. Proven
|
|
707
|
+
in `packages/dock/test/cli.test.ts`: the folder is written, refuses to
|
|
708
|
+
overwrite a package or take a name that is no domain, and its droplet
|
|
709
|
+
boots, `quo init` into it and a daemon answering the census.
|
|
710
|
+
|
|
711
|
+
Steps 1 to 22 are in the tree and on the machines, and MCP is complete;
|
|
712
|
+
the documents and the folders agree up to step 22. There is no gap named
|
|
713
|
+
today. `next.md` is where the next sitting starts.
|
|
677
714
|
|
|
678
715
|
## Glossary
|
|
679
716
|
|
|
@@ -688,7 +725,7 @@ the next sitting starts.
|
|
|
688
725
|
- **front desk**: the being every fresh client meets; hands out invitations
|
|
689
726
|
minted by user beings.
|
|
690
727
|
- **gate**: `for(occupant, asker)`; the whole permission model.
|
|
691
|
-
- **owner pilot**: `quo pilot`, the
|
|
728
|
+
- **owner pilot**: `quo pilot`, the model side over a ward's owner asks, reaching
|
|
692
729
|
the daemon's socket; with `--via`, a ward elsewhere through a standing.
|
|
693
730
|
- **daemon**: `quo serve`, the one process on a device that holds a harbor.
|
|
694
731
|
- **proof**: what a route produces after authenticating; the front desk
|
package/beings/setup.ts
CHANGED
|
@@ -15,7 +15,9 @@ export async function setup(hosted: Hosted, user: string): Promise<void> {
|
|
|
15
15
|
const out = (await hosted.ask('boot', args)) as { booted?: string; error?: string };
|
|
16
16
|
if (out.error) throw new Error(`init: ${out.error}`);
|
|
17
17
|
};
|
|
18
|
-
await boot({ key: 'desk', class: 'Desk'
|
|
18
|
+
await boot({ key: 'desk', class: 'Desk' });
|
|
19
|
+
const pub = (await hosted.ask('public', { key: 'desk' })) as { error?: string };
|
|
20
|
+
if (pub.error) throw new Error(`init: ${pub.error}`);
|
|
19
21
|
await boot({ key: user, class: 'User' });
|
|
20
22
|
const inv = (await hosted.ask('invite', { being: user, id: DESK })) as Invitation;
|
|
21
23
|
const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' })) as { taken?: string };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Three hostnames, one daemon, one loopback port. Caddy faces the world and
|
|
2
|
+
# terminates TLS; the daemon never does. Each hostname is a route, and a
|
|
3
|
+
# route is a path on the daemon's HTTP door. Replace DOMAIN.
|
|
4
|
+
#
|
|
5
|
+
# mcp. the model side: streamable HTTP MCP, with the credential exchange in front
|
|
6
|
+
# web. the bundle for plain tabs, and the exchange pages
|
|
7
|
+
# quo. the socket door for other harbors: requests in, sockets held, the rendezvous
|
|
8
|
+
#
|
|
9
|
+
# There is no cli. route. The root owner is the unix socket, reached on the
|
|
10
|
+
# device or over SSH, and never through here.
|
|
11
|
+
|
|
12
|
+
mcp.DOMAIN {
|
|
13
|
+
reverse_proxy 127.0.0.1:8787
|
|
14
|
+
rewrite * /mcp{uri}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
web.DOMAIN {
|
|
18
|
+
reverse_proxy 127.0.0.1:8787
|
|
19
|
+
rewrite * /web{uri}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
quo.DOMAIN {
|
|
23
|
+
reverse_proxy 127.0.0.1:8787
|
|
24
|
+
rewrite * /quo{uri}
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The daemon on a droplet, as one user, forever. Install with:
|
|
2
|
+
# sudo cp droplet/quo.service /etc/systemd/system/quo.service
|
|
3
|
+
# sudo systemctl enable --now quo
|
|
4
|
+
# The unit runs as the `quo` user, whose home holds the estate folder and
|
|
5
|
+
# the harbor folder, so the seed, the lease and the sockets are that user's
|
|
6
|
+
# alone. The HTTP door is loopback only; Caddy fronts it. See Caddyfile
|
|
7
|
+
# beside this file.
|
|
8
|
+
[Unit]
|
|
9
|
+
Description=quo: one harbor on this device
|
|
10
|
+
After=network.target
|
|
11
|
+
|
|
12
|
+
[Service]
|
|
13
|
+
User=quo
|
|
14
|
+
Group=quo
|
|
15
|
+
Environment=QUO_DIR=/home/quo/.quo
|
|
16
|
+
Environment=QUO_HTTP=8787
|
|
17
|
+
# QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
|
|
18
|
+
# CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
|
|
19
|
+
# reads its credential from the device, never from cells.
|
|
20
|
+
EnvironmentFile=-/etc/quo/env
|
|
21
|
+
WorkingDirectory=/home/quo/ESTATE
|
|
22
|
+
ExecStart=/usr/bin/node /home/quo/ESTATE/node_modules/@quo-systems/dock/dist/cli/quo.js serve
|
|
23
|
+
Restart=always
|
|
24
|
+
RestartSec=2
|
|
25
|
+
KillSignal=SIGTERM
|
|
26
|
+
TimeoutStopSec=10
|
|
27
|
+
NoNewPrivileges=true
|
|
28
|
+
ProtectSystem=strict
|
|
29
|
+
# Agents run in their folders and Claude Code keeps its own state in the
|
|
30
|
+
# user's home; both must be writable for a run to happen, and a harbor
|
|
31
|
+
# with no agent has neither.
|
|
32
|
+
ReadWritePaths=/home/quo/.quo -/home/quo/agents -/home/quo/.claude -/home/quo/.claude.json /tmp
|
|
33
|
+
PrivateTmp=false
|
|
34
|
+
|
|
35
|
+
[Install]
|
|
36
|
+
WantedBy=multi-user.target
|
package/cli/estate.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `quo estate DIR --domain D`: an estate folder, written once, of the shape
|
|
3
|
+
// every estate has. One folder per harbor, here the first, `droplet/`: the
|
|
4
|
+
// quo directory that device runs minus what it mints, its routes under the
|
|
5
|
+
// domain, no agents, no classes of its own, and that device's unit and
|
|
6
|
+
// Caddyfile. A package file that depends on this dock and nothing else, and
|
|
7
|
+
// one document to fill in. An estate needs nothing the dock does not give
|
|
8
|
+
// it, and this is the dock giving it.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
// This package's own version, read beside the emitted or the source tree.
|
|
15
|
+
async function version(): Promise<string> {
|
|
16
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
17
|
+
const at = fileURLToPath(new URL(rel, import.meta.url));
|
|
18
|
+
if (!existsSync(at)) continue;
|
|
19
|
+
const pkg = JSON.parse(await readFile(at, 'utf8')) as { name?: string; version?: string };
|
|
20
|
+
if (pkg.name === '@quo-systems/dock' && pkg.version) return pkg.version;
|
|
21
|
+
}
|
|
22
|
+
return '0.1.0';
|
|
23
|
+
}
|
|
24
|
+
const template = (name: string) => readFile(fileURLToPath(new URL(`estate/${name}`, import.meta.url)), 'utf8');
|
|
25
|
+
|
|
26
|
+
export async function estate(dir: string, domain: string): Promise<{ dir: string; name: string; files: string[] }> {
|
|
27
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) throw new Error(`estate: ${domain} is not a domain`);
|
|
28
|
+
if (existsSync(join(dir, 'package.json'))) throw new Error(`estate: ${dir} already holds a package`);
|
|
29
|
+
const name = basename(dir);
|
|
30
|
+
const files: Record<string, string> = {
|
|
31
|
+
'package.json': JSON.stringify({ name, private: true, description: `The ${name} estate: one owner's harbors, built on the dock and nothing else.`, type: 'module', dependencies: { '@quo-systems/dock': `^${await version()}` } }, null, 2) + '\n',
|
|
32
|
+
[`${name}.md`]: `# ${name}\n\nAn estate: all of one owner's harbors. One folder per harbor, each the quo\ndirectory that device runs minus what it mints, plus that device's unit.\n\`droplet/\` is the first: its routes under \`${domain}\`, its agents, its\nclasses, its systemd unit and its Caddyfile. This folder depends on the\ndock and nothing else.\n\n## The droplet\n\nWhere it is, how it is reached, and what was done to stand it up: yours\nto write.\n`,
|
|
33
|
+
'droplet/routes.json': JSON.stringify({ mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}` }) + '\n',
|
|
34
|
+
'droplet/agents.json': '{}\n',
|
|
35
|
+
'droplet/classes/index.ts': '// The classes this harbor holds beside the dock\'s built-in ones, exported\n// by name.\nexport {};\n',
|
|
36
|
+
'droplet/quo.service': (await template('quo.service')).replaceAll('ESTATE', name),
|
|
37
|
+
'droplet/Caddyfile': (await template('Caddyfile')).replaceAll('DOMAIN', domain),
|
|
38
|
+
};
|
|
39
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
40
|
+
await mkdir(join(dir, rel, '..'), { recursive: true });
|
|
41
|
+
await writeFile(join(dir, rel), text);
|
|
42
|
+
}
|
|
43
|
+
return { dir, name, files: Object.keys(files) };
|
|
44
|
+
}
|
package/cli/quo.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
|
|
16
16
|
// quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
|
|
17
17
|
// quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
|
|
18
|
+
// quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, on this dock
|
|
18
19
|
//
|
|
19
20
|
// D defaults to $QUO_DIR, then ~/.quo. W defaults to main. init takes
|
|
20
21
|
// --user NAME for the user being, defaulting to the device's user. run
|
|
@@ -31,8 +32,9 @@ import { DiskHarbor } from '../harbor/disk.ts';
|
|
|
31
32
|
import { serve } from './daemon.ts';
|
|
32
33
|
import { ask, side, reach, type Run } from './client.ts';
|
|
33
34
|
import { pilotSide } from '../mcp/pilot.ts';
|
|
35
|
+
import { estate } from './estate.ts';
|
|
34
36
|
|
|
35
|
-
const OWNER_ASKS = new Set(['boot', 'invite', 'knock', 'remove']);
|
|
37
|
+
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
|
|
36
38
|
|
|
37
39
|
function parse(argv: string[]) {
|
|
38
40
|
const flags: Record<string, string> = {};
|
|
@@ -80,7 +82,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
80
82
|
}
|
|
81
83
|
if (cmd === 'pilot') {
|
|
82
84
|
await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
|
|
83
|
-
const serving = await pilotSide(dir, ward, new StdioServerTransport(),
|
|
85
|
+
const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
|
|
84
86
|
process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
|
|
85
87
|
return -1;
|
|
86
88
|
}
|
|
@@ -93,6 +95,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
93
95
|
process.once('SIGTERM', stop);
|
|
94
96
|
return -1; // stays up
|
|
95
97
|
}
|
|
98
|
+
if (cmd === 'estate') {
|
|
99
|
+
if (!json || !flags.domain) {
|
|
100
|
+
console.error('quo estate needs <dir> --domain D');
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
print(await estate(json, flags.domain));
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
96
106
|
if (cmd === 'reach') {
|
|
97
107
|
if (!json || !more) {
|
|
98
108
|
console.error('quo reach needs <pk> <url>');
|
|
@@ -111,7 +121,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
111
121
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
112
122
|
return failed ? 1 : 0;
|
|
113
123
|
}
|
|
114
|
-
console.error('usage: quo init|serve|census|boot|invite|knock|remove|side|run|pilot|reach [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
124
|
+
console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
115
125
|
return 2;
|
|
116
126
|
}
|
|
117
127
|
|
package/dist/beings/setup.js
CHANGED
|
@@ -7,7 +7,10 @@ export async function setup(hosted, user) {
|
|
|
7
7
|
if (out.error)
|
|
8
8
|
throw new Error(`init: ${out.error}`);
|
|
9
9
|
};
|
|
10
|
-
await boot({ key: 'desk', class: 'Desk'
|
|
10
|
+
await boot({ key: 'desk', class: 'Desk' });
|
|
11
|
+
const pub = (await hosted.ask('public', { key: 'desk' }));
|
|
12
|
+
if (pub.error)
|
|
13
|
+
throw new Error(`init: ${pub.error}`);
|
|
11
14
|
await boot({ key: user, class: 'User' });
|
|
12
15
|
const inv = (await hosted.ask('invite', { being: user, id: DESK }));
|
|
13
16
|
const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Three hostnames, one daemon, one loopback port. Caddy faces the world and
|
|
2
|
+
# terminates TLS; the daemon never does. Each hostname is a route, and a
|
|
3
|
+
# route is a path on the daemon's HTTP door. Replace DOMAIN.
|
|
4
|
+
#
|
|
5
|
+
# mcp. the model side: streamable HTTP MCP, with the credential exchange in front
|
|
6
|
+
# web. the bundle for plain tabs, and the exchange pages
|
|
7
|
+
# quo. the socket door for other harbors: requests in, sockets held, the rendezvous
|
|
8
|
+
#
|
|
9
|
+
# There is no cli. route. The root owner is the unix socket, reached on the
|
|
10
|
+
# device or over SSH, and never through here.
|
|
11
|
+
|
|
12
|
+
mcp.DOMAIN {
|
|
13
|
+
reverse_proxy 127.0.0.1:8787
|
|
14
|
+
rewrite * /mcp{uri}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
web.DOMAIN {
|
|
18
|
+
reverse_proxy 127.0.0.1:8787
|
|
19
|
+
rewrite * /web{uri}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
quo.DOMAIN {
|
|
23
|
+
reverse_proxy 127.0.0.1:8787
|
|
24
|
+
rewrite * /quo{uri}
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The daemon on a droplet, as one user, forever. Install with:
|
|
2
|
+
# sudo cp droplet/quo.service /etc/systemd/system/quo.service
|
|
3
|
+
# sudo systemctl enable --now quo
|
|
4
|
+
# The unit runs as the `quo` user, whose home holds the estate folder and
|
|
5
|
+
# the harbor folder, so the seed, the lease and the sockets are that user's
|
|
6
|
+
# alone. The HTTP door is loopback only; Caddy fronts it. See Caddyfile
|
|
7
|
+
# beside this file.
|
|
8
|
+
[Unit]
|
|
9
|
+
Description=quo: one harbor on this device
|
|
10
|
+
After=network.target
|
|
11
|
+
|
|
12
|
+
[Service]
|
|
13
|
+
User=quo
|
|
14
|
+
Group=quo
|
|
15
|
+
Environment=QUO_DIR=/home/quo/.quo
|
|
16
|
+
Environment=QUO_HTTP=8787
|
|
17
|
+
# QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
|
|
18
|
+
# CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
|
|
19
|
+
# reads its credential from the device, never from cells.
|
|
20
|
+
EnvironmentFile=-/etc/quo/env
|
|
21
|
+
WorkingDirectory=/home/quo/ESTATE
|
|
22
|
+
ExecStart=/usr/bin/node /home/quo/ESTATE/node_modules/@quo-systems/dock/dist/cli/quo.js serve
|
|
23
|
+
Restart=always
|
|
24
|
+
RestartSec=2
|
|
25
|
+
KillSignal=SIGTERM
|
|
26
|
+
TimeoutStopSec=10
|
|
27
|
+
NoNewPrivileges=true
|
|
28
|
+
ProtectSystem=strict
|
|
29
|
+
# Agents run in their folders and Claude Code keeps its own state in the
|
|
30
|
+
# user's home; both must be writable for a run to happen, and a harbor
|
|
31
|
+
# with no agent has neither.
|
|
32
|
+
ReadWritePaths=/home/quo/.quo -/home/quo/agents -/home/quo/.claude -/home/quo/.claude.json /tmp
|
|
33
|
+
PrivateTmp=false
|
|
34
|
+
|
|
35
|
+
[Install]
|
|
36
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `quo estate DIR --domain D`: an estate folder, written once, of the shape
|
|
3
|
+
// every estate has. One folder per harbor, here the first, `droplet/`: the
|
|
4
|
+
// quo directory that device runs minus what it mints, its routes under the
|
|
5
|
+
// domain, no agents, no classes of its own, and that device's unit and
|
|
6
|
+
// Caddyfile. A package file that depends on this dock and nothing else, and
|
|
7
|
+
// one document to fill in. An estate needs nothing the dock does not give
|
|
8
|
+
// it, and this is the dock giving it.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
// This package's own version, read beside the emitted or the source tree.
|
|
14
|
+
async function version() {
|
|
15
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
16
|
+
const at = fileURLToPath(new URL(rel, import.meta.url));
|
|
17
|
+
if (!existsSync(at))
|
|
18
|
+
continue;
|
|
19
|
+
const pkg = JSON.parse(await readFile(at, 'utf8'));
|
|
20
|
+
if (pkg.name === '@quo-systems/dock' && pkg.version)
|
|
21
|
+
return pkg.version;
|
|
22
|
+
}
|
|
23
|
+
return '0.1.0';
|
|
24
|
+
}
|
|
25
|
+
const template = (name) => readFile(fileURLToPath(new URL(`estate/${name}`, import.meta.url)), 'utf8');
|
|
26
|
+
export async function estate(dir, domain) {
|
|
27
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain))
|
|
28
|
+
throw new Error(`estate: ${domain} is not a domain`);
|
|
29
|
+
if (existsSync(join(dir, 'package.json')))
|
|
30
|
+
throw new Error(`estate: ${dir} already holds a package`);
|
|
31
|
+
const name = basename(dir);
|
|
32
|
+
const files = {
|
|
33
|
+
'package.json': JSON.stringify({ name, private: true, description: `The ${name} estate: one owner's harbors, built on the dock and nothing else.`, type: 'module', dependencies: { '@quo-systems/dock': `^${await version()}` } }, null, 2) + '\n',
|
|
34
|
+
[`${name}.md`]: `# ${name}\n\nAn estate: all of one owner's harbors. One folder per harbor, each the quo\ndirectory that device runs minus what it mints, plus that device's unit.\n\`droplet/\` is the first: its routes under \`${domain}\`, its agents, its\nclasses, its systemd unit and its Caddyfile. This folder depends on the\ndock and nothing else.\n\n## The droplet\n\nWhere it is, how it is reached, and what was done to stand it up: yours\nto write.\n`,
|
|
35
|
+
'droplet/routes.json': JSON.stringify({ mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}` }) + '\n',
|
|
36
|
+
'droplet/agents.json': '{}\n',
|
|
37
|
+
'droplet/classes/index.ts': '// The classes this harbor holds beside the dock\'s built-in ones, exported\n// by name.\nexport {};\n',
|
|
38
|
+
'droplet/quo.service': (await template('quo.service')).replaceAll('ESTATE', name),
|
|
39
|
+
'droplet/Caddyfile': (await template('Caddyfile')).replaceAll('DOMAIN', domain),
|
|
40
|
+
};
|
|
41
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
42
|
+
await mkdir(join(dir, rel, '..'), { recursive: true });
|
|
43
|
+
await writeFile(join(dir, rel), text);
|
|
44
|
+
}
|
|
45
|
+
return { dir, name, files: Object.keys(files) };
|
|
46
|
+
}
|
package/dist/cli/quo.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
|
|
16
16
|
// quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
|
|
17
17
|
// quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
|
|
18
|
+
// quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, on this dock
|
|
18
19
|
//
|
|
19
20
|
// D defaults to $QUO_DIR, then ~/.quo. W defaults to main. init takes
|
|
20
21
|
// --user NAME for the user being, defaulting to the device's user. run
|
|
@@ -31,7 +32,8 @@ import { DiskHarbor } from '../harbor/disk.js';
|
|
|
31
32
|
import { serve } from './daemon.js';
|
|
32
33
|
import { ask, side, reach } from './client.js';
|
|
33
34
|
import { pilotSide } from '../mcp/pilot.js';
|
|
34
|
-
|
|
35
|
+
import { estate } from './estate.js';
|
|
36
|
+
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
|
|
35
37
|
function parse(argv) {
|
|
36
38
|
const flags = {};
|
|
37
39
|
const rest = [];
|
|
@@ -78,7 +80,7 @@ async function main(argv) {
|
|
|
78
80
|
}
|
|
79
81
|
if (cmd === 'pilot') {
|
|
80
82
|
await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
|
|
81
|
-
const serving = await pilotSide(dir, ward, new StdioServerTransport(),
|
|
83
|
+
const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
|
|
82
84
|
process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
|
|
83
85
|
return -1;
|
|
84
86
|
}
|
|
@@ -91,6 +93,14 @@ async function main(argv) {
|
|
|
91
93
|
process.once('SIGTERM', stop);
|
|
92
94
|
return -1; // stays up
|
|
93
95
|
}
|
|
96
|
+
if (cmd === 'estate') {
|
|
97
|
+
if (!json || !flags.domain) {
|
|
98
|
+
console.error('quo estate needs <dir> --domain D');
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
print(await estate(json, flags.domain));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
94
104
|
if (cmd === 'reach') {
|
|
95
105
|
if (!json || !more) {
|
|
96
106
|
console.error('quo reach needs <pk> <url>');
|
|
@@ -109,7 +119,7 @@ async function main(argv) {
|
|
|
109
119
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
110
120
|
return failed ? 1 : 0;
|
|
111
121
|
}
|
|
112
|
-
console.error('usage: quo init|serve|census|boot|invite|knock|remove|side|run|pilot|reach [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
122
|
+
console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
113
123
|
return 2;
|
|
114
124
|
}
|
|
115
125
|
main(process.argv.slice(2)).then((code) => {
|
|
@@ -63,7 +63,9 @@ function probe(base, state, env) {
|
|
|
63
63
|
learn(i, w);
|
|
64
64
|
}
|
|
65
65
|
wardOf.set(key, name);
|
|
66
|
-
assert.deepEqual(await w.ask('boot', { key, class: Class.name
|
|
66
|
+
assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
|
|
67
|
+
if (isPublic)
|
|
68
|
+
assert.deepEqual(await w.ask('public', { key }), { public: key });
|
|
67
69
|
if (Object.keys(cells).length)
|
|
68
70
|
Object.assign(at(name).p.beings[key], cells);
|
|
69
71
|
return {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The edge platform, as much of it as the harbor touches and no more. The
|
|
3
|
+
// vendor's own type package declares every global a worker has, and it
|
|
4
|
+
// collides with the DOM library this tree compiles against; these few
|
|
5
|
+
// lines are what the edge harbor and worker actually call.
|
|
6
|
+
export type Storage = {
|
|
7
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
8
|
+
get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
|
|
9
|
+
put(key: string, value: unknown): Promise<void>;
|
|
10
|
+
delete(key: string): Promise<boolean>;
|
|
11
|
+
list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
|
|
12
|
+
};
|
|
13
|
+
export type State = { id: { toString(): string; name?: string }; storage: Storage };
|
|
14
|
+
export type Stub = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
|
|
15
|
+
export type Namespace = { idFromName(name: string): unknown; get(id: unknown): Stub };
|
|
16
|
+
export type Env = { HARBOR: Namespace; QUO_SEED_KEY?: string; QUO_ROOT?: string; QUO_EXERCISE?: string };
|
|
17
|
+
// The server end of a WebSocketPair: the standard surface plus accept.
|
|
18
|
+
export type ServerSocket = { accept(): void; readyState: number; binaryType: string; send(data: string | Uint8Array): void; close(): void; addEventListener(type: string, fn: (e: never) => void): void };
|
|
19
|
+
declare global {
|
|
20
|
+
// eslint-disable-next-line no-var
|
|
21
|
+
var WebSocketPair: new () => { 0: ServerSocket; 1: ServerSocket };
|
|
22
|
+
interface ResponseInit {
|
|
23
|
+
webSocket?: unknown;
|
|
24
|
+
}
|
|
25
|
+
interface Response {
|
|
26
|
+
readonly webSocket?: unknown;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/mcp/oauth.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type Client = {
|
|
|
6
6
|
client_id: string;
|
|
7
7
|
client_name: string;
|
|
8
8
|
redirect_uris: string[];
|
|
9
|
+
exp: number;
|
|
9
10
|
};
|
|
10
11
|
export type Pending = {
|
|
11
12
|
client_id: string;
|
|
@@ -62,6 +63,7 @@ export declare class OAuth {
|
|
|
62
63
|
register(body: unknown): Promise<Client | {
|
|
63
64
|
error: string;
|
|
64
65
|
}>;
|
|
66
|
+
client(id: string): Client | undefined;
|
|
65
67
|
authorize(q: URLSearchParams): Promise<{
|
|
66
68
|
redirect: string;
|
|
67
69
|
} | {
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -60,15 +60,22 @@ export class OAuth {
|
|
|
60
60
|
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
|
|
61
61
|
if (uris.length === 0)
|
|
62
62
|
return { error: 'invalid_redirect_uri' };
|
|
63
|
-
const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
|
|
63
|
+
const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris, exp: this.now() + CODE_TTL };
|
|
64
64
|
this.store.clients[client.client_id] = client;
|
|
65
|
+
this.sweep();
|
|
65
66
|
await this.o.persist(this.store);
|
|
66
|
-
|
|
67
|
+
const { exp: _, ...shown } = client;
|
|
68
|
+
return shown;
|
|
69
|
+
}
|
|
70
|
+
// A client still alive, by id.
|
|
71
|
+
client(id) {
|
|
72
|
+
const c = own(this.store.clients, id);
|
|
73
|
+
return c && c.exp >= this.now() ? c : undefined;
|
|
67
74
|
}
|
|
68
75
|
// Start a request. What comes back is where to send the browser: the web
|
|
69
76
|
// route with the request id, or the client's redirect with an error.
|
|
70
77
|
async authorize(q) {
|
|
71
|
-
const client =
|
|
78
|
+
const client = this.client(q.get('client_id') ?? '');
|
|
72
79
|
const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
|
|
73
80
|
if (!client || redirect === null || !client.redirect_uris.includes(redirect))
|
|
74
81
|
return { error: 'invalid_client' };
|
|
@@ -89,7 +96,7 @@ export class OAuth {
|
|
|
89
96
|
const p = own(this.store.pending, id);
|
|
90
97
|
if (!p || p.exp < this.now())
|
|
91
98
|
return null;
|
|
92
|
-
const client =
|
|
99
|
+
const client = this.client(p.client_id);
|
|
93
100
|
return client ? { ...p, client } : null;
|
|
94
101
|
}
|
|
95
102
|
// The web route's last step: the human allowed this client as this identity.
|
|
@@ -140,6 +147,9 @@ export class OAuth {
|
|
|
140
147
|
const access_token = token(), refresh_token = token();
|
|
141
148
|
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
142
149
|
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
150
|
+
const c = own(this.store.clients, client_id);
|
|
151
|
+
if (c)
|
|
152
|
+
c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
143
153
|
this.sweep();
|
|
144
154
|
await this.o.persist(this.store);
|
|
145
155
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
@@ -162,10 +172,10 @@ export class OAuth {
|
|
|
162
172
|
}
|
|
163
173
|
sweep() {
|
|
164
174
|
const now = this.now();
|
|
165
|
-
for (const k of ['pending', 'codes', 'access', 'refresh'])
|
|
175
|
+
for (const k of ['clients', 'pending', 'codes', 'access', 'refresh'])
|
|
166
176
|
for (const [t, g] of Object.entries(this.store[k]))
|
|
167
|
-
if (g.exp
|
|
168
|
-
delete this.store[k][t];
|
|
177
|
+
if (!(g.exp >= now))
|
|
178
|
+
delete this.store[k][t]; // a record with no exp is from before there was one, and goes too
|
|
169
179
|
}
|
|
170
180
|
// The HTTP face. `rest` is the path under the route.
|
|
171
181
|
async handle(req, res, rest) {
|
package/dist/mcp/pilot.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
-
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
2
|
import type { Serving } from '../beings/side.ts';
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
export declare function pilotSide(dir: string, ward: string, transport: Transport,
|
|
3
|
+
import { type Subject } from './server.ts';
|
|
4
|
+
export declare function owner(dir: string, ward: string, via?: string): Subject;
|
|
5
|
+
export declare function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving>;
|
package/dist/mcp/pilot.js
CHANGED
|
@@ -1,68 +1,22 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The owner pilot: `quo pilot`. An MCP server over stdio that reaches a
|
|
3
|
-
// ward's owner asks through the daemon's root socket, and exposes four tools
|
|
4
|
-
// and no more, plus the one read. Whoever runs this process is the owner, by
|
|
5
|
-
// the device's own rules: the root of the ward here, or, with `via`, an
|
|
6
|
-
// owner at another ward's door, on a standing the user being here holds
|
|
7
|
-
// there. Every call is logged with what it made. The owner creates and
|
|
8
|
-
// places; the work goes through `quo side`, under a gate.
|
|
9
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
1
|
import { ask } from '../cli/client.js';
|
|
12
|
-
|
|
13
|
-
{
|
|
14
|
-
name: 'census',
|
|
15
|
-
description: 'the empty ask: the ward pk and every being, with class, public and digest',
|
|
16
|
-
inputSchema: { type: 'object', properties: {} },
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
name: 'boot',
|
|
20
|
-
description: 'boot a being by class name under a key; public marks the one public being',
|
|
21
|
-
inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
name: 'invite',
|
|
25
|
-
description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
|
|
26
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
name: 'knock',
|
|
30
|
-
description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
|
|
31
|
-
inputSchema: {
|
|
32
|
-
type: 'object',
|
|
33
|
-
properties: {
|
|
34
|
-
being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
|
|
35
|
-
id: { type: 'string' },
|
|
36
|
-
invitation: { type: 'object' },
|
|
37
|
-
method: { type: 'string' },
|
|
38
|
-
args: { type: 'object' },
|
|
39
|
-
wanted: { type: 'object', properties: { time: { type: 'number' } } },
|
|
40
|
-
},
|
|
41
|
-
required: ['being', 'id', 'invitation'],
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: 'remove',
|
|
46
|
-
description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
|
|
47
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
48
|
-
},
|
|
49
|
-
];
|
|
2
|
+
import { mcpSide } from './server.js';
|
|
50
3
|
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
|
|
4
|
+
// The owner hears objects: the socket's own failure, a silence and a word
|
|
5
|
+
// each come back as an error object named for what it was.
|
|
6
|
+
export function owner(dir, ward, via) {
|
|
7
|
+
const one = async (method, args) => {
|
|
8
|
+
const out = await ask(dir, method, args, ward, via);
|
|
9
|
+
if ('error' in out)
|
|
10
|
+
return { error: out.error };
|
|
11
|
+
const r = out.result;
|
|
12
|
+
if (isRecord(r) && r.silence === true)
|
|
13
|
+
return { error: 'silence' };
|
|
14
|
+
if (isRecord(r) && typeof r.word === 'string')
|
|
15
|
+
return { error: r.word };
|
|
16
|
+
return r;
|
|
17
|
+
};
|
|
18
|
+
return { tools: () => one(undefined, {}), call: (name, args) => one(name, args), ears: new Set() };
|
|
19
|
+
}
|
|
20
|
+
export function pilotSide(dir, ward, transport, via) {
|
|
21
|
+
return mcpSide(owner(dir, ward, via), transport);
|
|
68
22
|
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
2
|
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import type { Blueprint } from '@quo-systems/quo';
|
|
4
|
-
import type { Avatar } from '../beings/avatar.ts';
|
|
3
|
+
import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
|
|
5
4
|
import { type Serving } from '../beings/side.ts';
|
|
6
5
|
export declare const NAME = "quo";
|
|
7
6
|
export declare const VERSION = "0.0.0";
|
|
7
|
+
export type Subject = {
|
|
8
|
+
tools(): Promise<Blueprint | {
|
|
9
|
+
error: string;
|
|
10
|
+
}>;
|
|
11
|
+
call(name: string, args: JsonObject): Promise<Answer>;
|
|
12
|
+
ears: Set<(object: JsonObject) => void>;
|
|
13
|
+
};
|
|
14
|
+
export declare const DESCRIBE: Tool;
|
|
8
15
|
export declare function tools(bp: Blueprint): Tool[];
|
|
9
|
-
export declare function mcpSide(avatar:
|
|
16
|
+
export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The model side as an MCP server: one
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// The model side as an MCP server: one subject, one transport. The subject
|
|
3
|
+
// is whoever answers the empty ask and a named one: an avatar on her one
|
|
4
|
+
// standing, or a ward's owner asks through the pilot. The mapping is total
|
|
5
|
+
// and needs nothing beyond this envelope. tools/list is her describe, and
|
|
6
|
+
// the empty ask itself is the first tool, so the notes of the describe are
|
|
7
|
+
// readable; tools/call is a named ask, a push is a logging notification,
|
|
8
|
+
// and the three words for "no object" each cross as the table in
|
|
9
|
+
// quo-mcp.md says. The transport is whatever the caller connected: the
|
|
7
10
|
// SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
|
|
8
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
12
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
13
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
11
14
|
export const NAME = 'quo';
|
|
12
15
|
export const VERSION = '0.0.0';
|
|
13
|
-
//
|
|
14
|
-
|
|
16
|
+
// The empty ask as a tool: her describe whole, asks and notes.
|
|
17
|
+
export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
18
|
+
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
19
|
+
// input are verbatim; an output schema crosses when she declared one.
|
|
15
20
|
export function tools(bp) {
|
|
16
|
-
|
|
21
|
+
const asks = bp.asks.map((a) => {
|
|
17
22
|
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
18
23
|
if (a.description !== undefined)
|
|
19
24
|
t.description = a.description;
|
|
@@ -21,6 +26,7 @@ export function tools(bp) {
|
|
|
21
26
|
t.outputSchema = { ...a.output, type: 'object' };
|
|
22
27
|
return t;
|
|
23
28
|
});
|
|
29
|
+
return [DESCRIBE, ...asks];
|
|
24
30
|
}
|
|
25
31
|
// `after` runs when a call is done: a harbor that must write what the ward
|
|
26
32
|
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
@@ -33,7 +39,7 @@ export async function mcpSide(avatar, transport, after = async () => { }) {
|
|
|
33
39
|
return { tools: tools(bp) };
|
|
34
40
|
});
|
|
35
41
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
36
|
-
const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
42
|
+
const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
37
43
|
await after();
|
|
38
44
|
if (w.word === 'object') {
|
|
39
45
|
const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
|
package/harbor/edge/exercise.ts
CHANGED
|
@@ -70,7 +70,8 @@ function probe(base: string, state: State, env: Env): () => Promise<World> {
|
|
|
70
70
|
learn(i, w);
|
|
71
71
|
}
|
|
72
72
|
wardOf.set(key, name);
|
|
73
|
-
assert.deepEqual(await w.ask('boot', { key, class: Class.name
|
|
73
|
+
assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
|
|
74
|
+
if (isPublic) assert.deepEqual(await w.ask('public', { key }), { public: key });
|
|
74
75
|
if (Object.keys(cells).length) Object.assign(at(name).p.beings[key]!, cells);
|
|
75
76
|
return {
|
|
76
77
|
pk: w.pk,
|
package/mcp/oauth.ts
CHANGED
|
@@ -28,7 +28,10 @@ export const CODE_TTL = 10 * 60 * 1000; // a code, and a pending request, live t
|
|
|
28
28
|
export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
|
|
29
29
|
export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// A client lives ten minutes from registration, the life of a request, and
|
|
32
|
+
// as long as its refresh token once the human allowed it: the register door
|
|
33
|
+
// is open to anyone, and what nobody allowed must not stay on disk.
|
|
34
|
+
export type Client = { client_id: string; client_name: string; redirect_uris: string[]; exp: number };
|
|
32
35
|
export type Pending = { client_id: string; redirect_uri: string; challenge: string; state: string | null; resource: string | null; exp: number };
|
|
33
36
|
export type Grant = { identity: string; client_id: string; exp: number };
|
|
34
37
|
export type Store = {
|
|
@@ -87,16 +90,24 @@ export class OAuth {
|
|
|
87
90
|
const b = body as { client_name?: unknown; redirect_uris?: unknown };
|
|
88
91
|
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
|
|
89
92
|
if (uris.length === 0) return { error: 'invalid_redirect_uri' };
|
|
90
|
-
const client: Client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
|
|
93
|
+
const client: Client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris, exp: this.now() + CODE_TTL };
|
|
91
94
|
this.store.clients[client.client_id] = client;
|
|
95
|
+
this.sweep();
|
|
92
96
|
await this.o.persist(this.store);
|
|
93
|
-
|
|
97
|
+
const { exp: _, ...shown } = client;
|
|
98
|
+
return shown as Client;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A client still alive, by id.
|
|
102
|
+
client(id: string): Client | undefined {
|
|
103
|
+
const c = own(this.store.clients, id);
|
|
104
|
+
return c && c.exp >= this.now() ? c : undefined;
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
// Start a request. What comes back is where to send the browser: the web
|
|
97
108
|
// route with the request id, or the client's redirect with an error.
|
|
98
109
|
async authorize(q: URLSearchParams): Promise<{ redirect: string } | { error: string }> {
|
|
99
|
-
const client =
|
|
110
|
+
const client = this.client(q.get('client_id') ?? '');
|
|
100
111
|
const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
|
|
101
112
|
if (!client || redirect === null || !client.redirect_uris.includes(redirect)) return { error: 'invalid_client' };
|
|
102
113
|
const back = (error: string) => ({ redirect: withQuery(redirect, { error, state: q.get('state') }) });
|
|
@@ -114,7 +125,7 @@ export class OAuth {
|
|
|
114
125
|
pending(id: string): (Pending & { client: Client }) | null {
|
|
115
126
|
const p = own(this.store.pending, id);
|
|
116
127
|
if (!p || p.exp < this.now()) return null;
|
|
117
|
-
const client =
|
|
128
|
+
const client = this.client(p.client_id);
|
|
118
129
|
return client ? { ...p, client } : null;
|
|
119
130
|
}
|
|
120
131
|
|
|
@@ -166,6 +177,8 @@ export class OAuth {
|
|
|
166
177
|
refresh_token = token();
|
|
167
178
|
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
168
179
|
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
180
|
+
const c = own(this.store.clients, client_id);
|
|
181
|
+
if (c) c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
169
182
|
this.sweep();
|
|
170
183
|
await this.o.persist(this.store);
|
|
171
184
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
@@ -188,7 +201,7 @@ export class OAuth {
|
|
|
188
201
|
|
|
189
202
|
sweep() {
|
|
190
203
|
const now = this.now();
|
|
191
|
-
for (const k of ['pending', 'codes', 'access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.exp
|
|
204
|
+
for (const k of ['clients', 'pending', 'codes', 'access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (!(g.exp >= now)) delete this.store[k][t]; // a record with no exp is from before there was one, and goes too
|
|
192
205
|
}
|
|
193
206
|
|
|
194
207
|
// The HTTP face. `rest` is the path under the route.
|
package/mcp/pilot.ts
CHANGED
|
@@ -1,73 +1,34 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The owner pilot: `quo pilot`.
|
|
3
|
-
// ward
|
|
4
|
-
// and
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// places; the work goes through
|
|
9
|
-
|
|
2
|
+
// The owner pilot: `quo pilot`. The model side pointed at a ward instead of
|
|
3
|
+
// an avatar. The ward is a being to her owner, so her describe is the tool
|
|
4
|
+
// list and a tool call is an owner ask, exactly as for anyone; nothing here
|
|
5
|
+
// names an ask. Whoever runs this process is the owner, by the device's own
|
|
6
|
+
// rules: the root of the ward here, through the daemon's socket, or, with
|
|
7
|
+
// `via`, an owner at another ward's door, on a standing the user being here
|
|
8
|
+
// holds there. The owner creates and places; the work goes through
|
|
9
|
+
// `quo side`, under a gate.
|
|
10
10
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
11
|
-
import {
|
|
11
|
+
import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
|
|
12
12
|
import { ask } from '../cli/client.ts';
|
|
13
13
|
import type { Serving } from '../beings/side.ts';
|
|
14
|
+
import { mcpSide, type Subject } from './server.ts';
|
|
14
15
|
|
|
15
|
-
export const PILOT_TOOLS: Tool[] = [
|
|
16
|
-
{
|
|
17
|
-
name: 'census',
|
|
18
|
-
description: 'the empty ask: the ward pk and every being, with class, public and digest',
|
|
19
|
-
inputSchema: { type: 'object', properties: {} },
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
name: 'boot',
|
|
23
|
-
description: 'boot a being by class name under a key; public marks the one public being',
|
|
24
|
-
inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
name: 'invite',
|
|
28
|
-
description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
|
|
29
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: 'knock',
|
|
33
|
-
description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
|
|
34
|
-
inputSchema: {
|
|
35
|
-
type: 'object',
|
|
36
|
-
properties: {
|
|
37
|
-
being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
|
|
38
|
-
id: { type: 'string' },
|
|
39
|
-
invitation: { type: 'object' },
|
|
40
|
-
method: { type: 'string' },
|
|
41
|
-
args: { type: 'object' },
|
|
42
|
-
wanted: { type: 'object', properties: { time: { type: 'number' } } },
|
|
43
|
-
},
|
|
44
|
-
required: ['being', 'id', 'invitation'],
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
name: 'remove',
|
|
49
|
-
description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
|
|
50
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
51
|
-
},
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
export type Log = (line: string) => void;
|
|
55
16
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
56
17
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return
|
|
18
|
+
// The owner hears objects: the socket's own failure, a silence and a word
|
|
19
|
+
// each come back as an error object named for what it was.
|
|
20
|
+
export function owner(dir: string, ward: string, via?: string): Subject {
|
|
21
|
+
const one = async (method: string | undefined, args: JsonObject): Promise<Answer> => {
|
|
22
|
+
const out = await ask(dir, method, args, ward, via);
|
|
23
|
+
if ('error' in out) return { error: out.error };
|
|
24
|
+
const r = out.result;
|
|
25
|
+
if (isRecord(r) && r.silence === true) return { error: 'silence' };
|
|
26
|
+
if (isRecord(r) && typeof r.word === 'string') return { error: r.word };
|
|
27
|
+
return r as Answer;
|
|
28
|
+
};
|
|
29
|
+
return { tools: () => one(undefined, {}) as Promise<Blueprint | { error: string }>, call: (name, args) => one(name, args), ears: new Set() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving> {
|
|
33
|
+
return mcpSide(owner(dir, ward, via), transport);
|
|
73
34
|
}
|
package/mcp/quo-mcp.md
CHANGED
|
@@ -23,6 +23,8 @@ and needs no translation code beyond an envelope:
|
|
|
23
23
|
| server | an avatar's side |
|
|
24
24
|
| session | a standing the avatar holds, persistent across sessions |
|
|
25
25
|
| tools/list | the empty ask on that standing: describe for this asker |
|
|
26
|
+
| the `describe` tool | the empty ask itself, first in the list, so the notes of the |
|
|
27
|
+
| | describe are readable and not only its asks |
|
|
26
28
|
| tool name, inputSchema | `asks[].name`, `asks[].input`, verbatim |
|
|
27
29
|
| tools/call | a named ask with args |
|
|
28
30
|
| result content | the answer object, as JSON |
|
|
@@ -198,23 +200,20 @@ the device and crosses only the local socket.
|
|
|
198
200
|
|
|
199
201
|
### A model as the owner
|
|
200
202
|
|
|
201
|
-
An **owner pilot** is
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
own rules. A remote pilot is a standing at the ward, and still not a route:
|
|
216
|
-
a model without a ward of its own cannot be an owner anywhere. `quo pilot
|
|
217
|
-
--via S` is that pilot: S is a standing the user being here holds at the
|
|
203
|
+
An **owner pilot** is the model side above, pointed at a ward instead of an
|
|
204
|
+
avatar. The ward is a being to her owner, and `packages/quo/SPEC.md` says her
|
|
205
|
+
describe carries her asks with a description and an input each, so the
|
|
206
|
+
pilot holds no list of its own: tools/list is the ward's describe, the
|
|
207
|
+
`describe` tool is the census, and a tool call is an owner ask, boot,
|
|
208
|
+
public, invite, knock or remove, in the ward's own words. It reaches those
|
|
209
|
+
asks either as the root through the daemon's socket on the device, or as an
|
|
210
|
+
owner the root invited, through a standing over the sealed door. It keeps
|
|
211
|
+
nothing and logs nothing: what a model did with it is in the host's own
|
|
212
|
+
transcript, and an invitation it was handed is in no file of the dock's.
|
|
213
|
+
The pilot is handed to one local agent by the device's own rules. A remote
|
|
214
|
+
pilot is a standing at the ward, and still not a route: a model without a
|
|
215
|
+
ward of its own cannot be an owner anywhere. `quo pilot --via S` is that
|
|
216
|
+
pilot: S is a standing the user being here holds at the
|
|
218
217
|
far ward, taken when the root here knocked for her with an invitation the
|
|
219
218
|
far root minted on its ward's pk, and every tool call is a sealed ask
|
|
220
219
|
there, answered as the far ward answers an owner at its door.
|
|
@@ -259,4 +258,4 @@ On top of the shared ones in the trunk:
|
|
|
259
258
|
under; governance, never permission.
|
|
260
259
|
- **runner**: an inline MCP client for a model that speaks function calling;
|
|
261
260
|
it drives the loop the MCP client would, over one conversation.
|
|
262
|
-
- **owner pilot**:
|
|
261
|
+
- **owner pilot**: the model side over a ward's owner asks, over stdio.
|
package/mcp/server.ts
CHANGED
|
@@ -1,34 +1,48 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The model side as an MCP server: one
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// The model side as an MCP server: one subject, one transport. The subject
|
|
3
|
+
// is whoever answers the empty ask and a named one: an avatar on her one
|
|
4
|
+
// standing, or a ward's owner asks through the pilot. The mapping is total
|
|
5
|
+
// and needs nothing beyond this envelope. tools/list is her describe, and
|
|
6
|
+
// the empty ask itself is the first tool, so the notes of the describe are
|
|
7
|
+
// readable; tools/call is a named ask, a push is a logging notification,
|
|
8
|
+
// and the three words for "no object" each cross as the table in
|
|
9
|
+
// quo-mcp.md says. The transport is whatever the caller connected: the
|
|
7
10
|
// SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
|
|
8
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
12
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
10
13
|
import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
-
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
12
|
-
import type { Avatar } from '../beings/avatar.ts';
|
|
14
|
+
import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
|
|
13
15
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
|
|
14
16
|
|
|
15
17
|
export const NAME = 'quo';
|
|
16
18
|
export const VERSION = '0.0.0';
|
|
17
19
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
+
// What a side speaks for: the empty ask and a named one, and the ears a
|
|
21
|
+
// push reaches. An avatar is one; the owner's asks on a ward are another.
|
|
22
|
+
export type Subject = {
|
|
23
|
+
tools(): Promise<Blueprint | { error: string }>;
|
|
24
|
+
call(name: string, args: JsonObject): Promise<Answer>;
|
|
25
|
+
ears: Set<(object: JsonObject) => void>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// The empty ask as a tool: her describe whole, asks and notes.
|
|
29
|
+
export const DESCRIBE: Tool = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
30
|
+
|
|
31
|
+
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
32
|
+
// input are verbatim; an output schema crosses when she declared one.
|
|
20
33
|
export function tools(bp: Blueprint): Tool[] {
|
|
21
|
-
|
|
34
|
+
const asks = bp.asks.map((a) => {
|
|
22
35
|
const t: Tool = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
23
36
|
if (a.description !== undefined) t.description = a.description;
|
|
24
37
|
if (a.output !== undefined) t.outputSchema = { ...a.output, type: 'object' };
|
|
25
38
|
return t;
|
|
26
39
|
});
|
|
40
|
+
return [DESCRIBE, ...asks];
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
// `after` runs when a call is done: a harbor that must write what the ward
|
|
30
44
|
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
31
|
-
export async function mcpSide(avatar:
|
|
45
|
+
export async function mcpSide(avatar: Subject, transport: Transport, after: () => Promise<void> = async () => {}): Promise<Serving> {
|
|
32
46
|
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
33
47
|
|
|
34
48
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -38,7 +52,7 @@ export async function mcpSide(avatar: Avatar, transport: Transport, after: () =>
|
|
|
38
52
|
});
|
|
39
53
|
|
|
40
54
|
server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
|
|
41
|
-
const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {}) as JsonObject));
|
|
55
|
+
const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {}) as JsonObject));
|
|
42
56
|
await after();
|
|
43
57
|
if (w.word === 'object') {
|
|
44
58
|
const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quo-systems/dock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The dock: what every estate on Quo needs and nobody writes twice. A daemon and the quo command, the front desk, the user being and the avatar, harbors on disk, in a tab and on the edge, the model sides and the screen.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quo",
|
|
@@ -54,10 +54,14 @@
|
|
|
54
54
|
"./human/html": {
|
|
55
55
|
"types": "./dist/human/html.d.ts",
|
|
56
56
|
"default": "./dist/human/html.js"
|
|
57
|
+
},
|
|
58
|
+
"./harbor/edge/worker": {
|
|
59
|
+
"types": "./dist/harbor/edge/worker.d.ts",
|
|
60
|
+
"default": "./dist/harbor/edge/worker.js"
|
|
57
61
|
}
|
|
58
62
|
},
|
|
59
63
|
"scripts": {
|
|
60
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
64
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp harbor/edge/platform.d.ts dist/harbor/edge/ && cp -R cli/estate dist/cli/",
|
|
61
65
|
"test": "node --test \"test/*.test.ts\"",
|
|
62
66
|
"check:terrain": "node --test \"test/terrain/*.test.ts\"",
|
|
63
67
|
"prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"
|