@quo-systems/dock 0.2.0 → 0.2.2
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/beings/avatar.ts +9 -3
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +3 -1
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +163 -277
- package/beings/side.ts +10 -1
- package/beings/user.ts +67 -13
- package/cli/daemon.ts +58 -251
- package/cli/http.ts +70 -0
- package/cli/quo.ts +13 -5
- package/dist/beings/avatar.js +10 -3
- package/dist/beings/carry.d.ts +10 -0
- package/dist/beings/carry.js +106 -0
- package/dist/beings/desk.d.ts +1 -0
- package/dist/beings/desk.js +1 -1
- package/dist/beings/index.d.ts +2 -0
- package/dist/beings/index.js +3 -1
- package/dist/beings/link.d.ts +7 -0
- package/dist/beings/link.js +42 -0
- package/dist/beings/look.d.ts +27 -0
- package/dist/beings/look.js +71 -0
- package/dist/beings/side.d.ts +8 -1
- package/dist/beings/user.d.ts +29 -3
- package/dist/beings/user.js +71 -14
- package/dist/cli/daemon.d.ts +7 -19
- package/dist/cli/daemon.js +51 -240
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +15 -5
- package/dist/harbor/quo.d.ts +4 -0
- package/dist/harbor/quo.js +53 -0
- package/dist/human/door.d.ts +5 -0
- package/dist/human/door.js +19 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +25 -0
- package/dist/human/html.d.ts +10 -2
- package/dist/human/html.js +61 -10
- package/dist/human/screen.d.ts +8 -3
- package/dist/human/screen.js +26 -6
- package/dist/human/tab.d.ts +5 -0
- package/dist/human/tab.js +161 -43
- package/dist/human/web.d.ts +9 -0
- package/dist/human/web.js +95 -0
- package/dist/human/worlds.d.ts +10 -0
- package/dist/human/worlds.js +38 -0
- package/dist/mcp/http.d.ts +4 -3
- package/dist/mcp/http.js +6 -6
- package/dist/mcp/oauth.d.ts +9 -4
- package/dist/mcp/oauth.js +15 -14
- package/dist/mcp/pilot.js +1 -1
- package/dist/mcp/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- package/dist/mcp/server.d.ts +5 -10
- package/dist/mcp/server.js +35 -4
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/quo-harbor.md +67 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +158 -67
- package/human/screen.ts +34 -8
- package/human/tab.ts +193 -53
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +20 -2
- package/mcp/route.ts +39 -0
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/mcp/quo-mcp.md
CHANGED
|
@@ -31,13 +31,24 @@ and needs no translation code beyond an envelope:
|
|
|
31
31
|
| tool error result | an error object she answered, `{ error }`, as JSON |
|
|
32
32
|
| protocol error | silence and the ward's words, see the table below |
|
|
33
33
|
| auth token | an invitation, used once, then keys |
|
|
34
|
-
|
|
|
34
|
+
| tool title, annotations | her `look`: a title and the hints per ask, see below |
|
|
35
|
+
| resources, prompts | not mapped. |
|
|
35
36
|
|
|
36
37
|
A tool list is a describe. Because a being describes per asker, two models
|
|
37
38
|
connected to the same user being see two different tool lists, and neither
|
|
38
39
|
can call what it cannot see: the gate is one decision for describe and for
|
|
39
40
|
dispatch.
|
|
40
41
|
|
|
42
|
+
A being who answers `look`, the trunk's one optional ask, is listed with
|
|
43
|
+
what it says: `asks.NAME.title` is the tool's title, and `readOnly`,
|
|
44
|
+
`destructive` and `idempotent` are the annotations of the same names with
|
|
45
|
+
`Hint` after them. The `look` ask itself is not a tool. A carried standing's
|
|
46
|
+
asks, `acme-book` on the user being for a model the human let reach, carry
|
|
47
|
+
the far being's hints the same way, read from the notes the carrier writes.
|
|
48
|
+
The side asks `look` once per digest of the describe. Nothing else changes:
|
|
49
|
+
a model sees a flat list with a dash, exactly as a screen sees a section
|
|
50
|
+
per standing, and neither can do a thing the other cannot.
|
|
51
|
+
|
|
41
52
|
The three words for "no object" cross the envelope like this:
|
|
42
53
|
|
|
43
54
|
| the avatar heard | the client gets |
|
|
@@ -56,6 +67,13 @@ the two words give it what it needs to decide.
|
|
|
56
67
|
|
|
57
68
|
### A remote MCP client connects
|
|
58
69
|
|
|
70
|
+
One harbor holds many worlds, and the allow page names the one the client
|
|
71
|
+
is let into; the grant remembers it, so a bearer is an identity in a world
|
|
72
|
+
and a session is that identity's there. `/mcp` stays one endpoint per
|
|
73
|
+
harbor: `mcp/route.ts` is the route the daemon mounts, the exchange in
|
|
74
|
+
`oauth.ts` in front and the endpoint in `http.ts` behind it, with the
|
|
75
|
+
route's store, `<dir>/oauth.json`, the route's own and never a ward's.
|
|
76
|
+
|
|
59
77
|
```
|
|
60
78
|
client mcp. route front desk user being avatar
|
|
61
79
|
|-- OAuth ------->| | | |
|
|
@@ -205,7 +223,7 @@ avatar. The ward is a being to her owner, and `packages/quo/SPEC.md` says her
|
|
|
205
223
|
describe carries her asks with a description and an input each, so the
|
|
206
224
|
pilot holds no list of its own: tools/list is the ward's describe, the
|
|
207
225
|
`describe` tool is the census, and a tool call is an owner ask, boot,
|
|
208
|
-
public, invite, knock or
|
|
226
|
+
public, invite, knock, remove or unboot, in the ward's own words. It reaches those
|
|
209
227
|
asks either as the root through the daemon's socket on the device, or as an
|
|
210
228
|
owner the root invited, through a standing over the sealed door. It keeps
|
|
211
229
|
nothing and logs nothing: what a model did with it is in the host's own
|
package/mcp/route.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The mcp. route: the credential exchange and the MCP endpoint, served by
|
|
3
|
+
// the daemon under `/mcp` and mapped by the proxy from the mcp. hostname.
|
|
4
|
+
// The OAuth store is `<dir>/oauth.json`, the route's own and never a
|
|
5
|
+
// ward's: clients, pending requests, codes and tokens, each mapping to a
|
|
6
|
+
// client identity at most. A bearer names an identity in a world; the
|
|
7
|
+
// endpoint hands both to the model side, which runs beside her avatar.
|
|
8
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { OAuth, emptyStore, type Store } from './oauth.ts';
|
|
12
|
+
import type { McpHttp } from './http.ts';
|
|
13
|
+
import type { Handler } from '../cli/http.ts';
|
|
14
|
+
|
|
15
|
+
// The routes' public faces, as the exchange and the metadata name them.
|
|
16
|
+
export type Routes = { mcp: string; web: string; quo?: string };
|
|
17
|
+
|
|
18
|
+
export async function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null): Promise<{ oauth: OAuth; handler: Handler }> {
|
|
19
|
+
const file = join(dir, 'oauth.json');
|
|
20
|
+
const store: Store = existsSync(file) ? { ...emptyStore(), ...(JSON.parse(await readFile(file, 'utf8')) as Partial<Store>) } : emptyStore();
|
|
21
|
+
let queue = Promise.resolve();
|
|
22
|
+
const persist = (s: Store) =>
|
|
23
|
+
(queue = queue.then(async () => {
|
|
24
|
+
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
25
|
+
await rename(file + '.tmp', file);
|
|
26
|
+
}));
|
|
27
|
+
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
28
|
+
const handler: Handler = async (req, res, rest) => {
|
|
29
|
+
if (await oauth.handle(req, res, rest)) return;
|
|
30
|
+
if (rest === '/mcp') {
|
|
31
|
+
const grant = oauth.bearer(req);
|
|
32
|
+
if (grant === null || !mcp) return oauth.challenge(res);
|
|
33
|
+
return mcp.handle(req, res, grant.identity, grant.ward);
|
|
34
|
+
}
|
|
35
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
36
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
37
|
+
};
|
|
38
|
+
return { oauth, handler };
|
|
39
|
+
}
|
package/mcp/server.ts
CHANGED
|
@@ -11,32 +11,40 @@
|
|
|
11
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
12
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
13
13
|
import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
14
|
-
import type {
|
|
15
|
-
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
|
|
14
|
+
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
15
|
+
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving, type Subject } from '../beings/side.ts';
|
|
16
|
+
import { hintFor, sanitise, type Look } from '../beings/look.ts';
|
|
17
|
+
import { isSilence, isWord, digest } from '@quo-systems/quo';
|
|
18
|
+
import type { Json } from '@quo-systems/quo';
|
|
16
19
|
|
|
17
20
|
export const NAME = 'quo';
|
|
18
21
|
export const VERSION = '0.0.0';
|
|
19
22
|
|
|
20
|
-
|
|
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
|
-
};
|
|
23
|
+
export type { Subject } from '../beings/side.ts';
|
|
27
24
|
|
|
28
25
|
// The empty ask as a tool: her describe whole, asks and notes.
|
|
29
26
|
export const DESCRIBE: Tool = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
30
27
|
|
|
31
28
|
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
32
|
-
// input are verbatim; an output schema crosses when she declared one.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
// input are verbatim; an output schema crosses when she declared one. Her
|
|
30
|
+
// look, when she has one, is the hints: a title, and the annotations a host
|
|
31
|
+
// reads. The `look` ask itself is presentation, and is not a tool.
|
|
32
|
+
export function tools(bp: Blueprint, look: Look = {}): Tool[] {
|
|
33
|
+
const asks = bp.asks
|
|
34
|
+
.filter((a) => a.name !== 'look')
|
|
35
|
+
.map((a) => {
|
|
36
|
+
const t: Tool = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
37
|
+
if (a.description !== undefined) t.description = a.description;
|
|
38
|
+
if (a.output !== undefined) t.outputSchema = { ...a.output, type: 'object' };
|
|
39
|
+
const h = hintFor(bp, look, a.name);
|
|
40
|
+
if (h.title !== undefined) t.title = h.title;
|
|
41
|
+
const notes: Record<string, boolean> = {};
|
|
42
|
+
if (h.readOnly !== undefined) notes.readOnlyHint = h.readOnly;
|
|
43
|
+
if (h.destructive !== undefined) notes.destructiveHint = h.destructive;
|
|
44
|
+
if (h.idempotent !== undefined) notes.idempotentHint = h.idempotent;
|
|
45
|
+
if (Object.keys(notes).length) t.annotations = notes;
|
|
46
|
+
return t;
|
|
47
|
+
});
|
|
40
48
|
return [DESCRIBE, ...asks];
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -45,10 +53,21 @@ export function tools(bp: Blueprint): Tool[] {
|
|
|
45
53
|
export async function mcpSide(avatar: Subject, transport: Transport, after: () => Promise<void> = async () => {}): Promise<Serving> {
|
|
46
54
|
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
47
55
|
|
|
56
|
+
// Her look, asked once per digest of her describe.
|
|
57
|
+
let seen: string | null = null;
|
|
58
|
+
let look: Look = {};
|
|
48
59
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
49
60
|
const bp = await avatar.tools();
|
|
50
61
|
if ('error' in bp && !('asks' in bp)) return { tools: [] }; // not joined: nothing to show, and nothing to call
|
|
51
|
-
|
|
62
|
+
const d = await digest(bp as Blueprint);
|
|
63
|
+
if (d !== seen) {
|
|
64
|
+
seen = d;
|
|
65
|
+
if ((bp as Blueprint).asks.some((a) => a.name === 'look')) {
|
|
66
|
+
const l = await avatar.call('look', {});
|
|
67
|
+
look = isSilence(l) || isWord(l) ? {} : sanitise(l as Json);
|
|
68
|
+
} else look = {};
|
|
69
|
+
}
|
|
70
|
+
return { tools: tools(bp as Blueprint, look) };
|
|
52
71
|
});
|
|
53
72
|
|
|
54
73
|
server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
|
package/mcp/web/exchange.ts
CHANGED
|
@@ -20,12 +20,12 @@ import { readForm } from '../oauth.ts';
|
|
|
20
20
|
export const SESSION_TTL = 10 * 60 * 1000; // a login lives as long as a request: ten minutes
|
|
21
21
|
const COOKIE = 'quo_exchange';
|
|
22
22
|
|
|
23
|
-
export type Admit = (identity: string, wake: boolean) => Promise<{ error?: string }>;
|
|
23
|
+
export type Admit = (identity: string, wake: boolean, reach: boolean, ward: string) => Promise<{ error?: string }>;
|
|
24
24
|
export type Options = {
|
|
25
25
|
oauth: OAuth;
|
|
26
26
|
password: () => string | undefined; // QUO_OWNER_PASSWORD, read at every login
|
|
27
|
-
admit: Admit; // the daemon: boot or find the avatar for this identity, and enter her
|
|
28
|
-
user: string; // the user being's name
|
|
27
|
+
admit: Admit; // the daemon: boot or find the avatar for this identity in that world, and enter her
|
|
28
|
+
worlds: () => { ward: string; user: string }[]; // the worlds of this harbor, the first the default: a ward with a public being, and its user being's name
|
|
29
29
|
now?: () => number;
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -101,7 +101,7 @@ export class Exchange {
|
|
|
101
101
|
const p = this.o.oauth.pending(request);
|
|
102
102
|
if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
103
103
|
if (!this.valid(req)) return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
104
|
-
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.
|
|
104
|
+
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds())), true;
|
|
105
105
|
}
|
|
106
106
|
if (rest === '/allow' && req.method === 'POST') {
|
|
107
107
|
const f = await readForm(req);
|
|
@@ -113,11 +113,14 @@ export class Exchange {
|
|
|
113
113
|
const out = await this.o.oauth.deny(request);
|
|
114
114
|
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`), true;
|
|
115
115
|
}
|
|
116
|
+
const worlds = this.o.worlds();
|
|
117
|
+
const world = worlds.find((w) => w.ward === (f.get('ward') ?? worlds[0]?.ward));
|
|
118
|
+
if (!world) return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'That is not a world of this harbor.')), true;
|
|
116
119
|
const identity = word(f.get('identity'));
|
|
117
|
-
if (identity === null || identity ===
|
|
118
|
-
const admitted = await this.o.admit(identity, f.get('wake') === 'on');
|
|
120
|
+
if (identity === null || identity === world.user || identity === 'desk') return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'An identity is one word, and not the user or the desk.')), true;
|
|
121
|
+
const admitted = await this.o.admit(identity, f.get('wake') === 'on', f.get('reach') === 'on', world.ward);
|
|
119
122
|
if (admitted.error) return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
|
|
120
|
-
const out = await this.o.oauth.complete(request, identity);
|
|
123
|
+
const out = await this.o.oauth.complete(request, identity, world.ward);
|
|
121
124
|
return 'redirect' in out ? go(out.redirect, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
|
|
122
125
|
}
|
|
123
126
|
return false;
|
|
@@ -135,12 +138,23 @@ ${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
|
135
138
|
<label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
136
139
|
<button type="submit">Log in</button></form>`;
|
|
137
140
|
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
// The world is a choice when the harbor has more than one; the user named
|
|
142
|
+
// is the first world's, and the page says which world each identity lands in.
|
|
143
|
+
const allowForm = (request: string, client: string, redirect: string, identity: string, worlds: { ward: string; user: string }[], err = '') => {
|
|
144
|
+
const user = worlds[0]?.user ?? '';
|
|
145
|
+
const pick =
|
|
146
|
+
worlds.length > 1
|
|
147
|
+
? `<label for="wd">World</label><select id="wd" name="ward">${worlds.map((w) => `<option value="${esc(w.ward)}">${esc(w.ward)}, ${esc(w.user)}'s</option>`).join('')}</select>`
|
|
148
|
+
: `<input type="hidden" name="ward" value="${esc(worlds[0]?.ward ?? 'main')}">`;
|
|
149
|
+
return `<h1>Allow ${esc(client)}?</h1>
|
|
150
|
+
<div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of <strong>${esc(user)}</strong>${worlds.length > 1 ? ', or of another world below' : ''}.</p>
|
|
140
151
|
<p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
|
|
141
152
|
<p>It returns to <code>${esc(redirect)}</code>.</p></div>
|
|
142
153
|
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
143
154
|
<form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
|
|
155
|
+
${pick}
|
|
144
156
|
<label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
|
|
157
|
+
<label for="r"><input id="r" name="reach" type="checkbox" style="width:auto"> May reach what ${esc(user)} holds: her standings, acme and the rest, as asks of hers</label>
|
|
145
158
|
<label for="w"><input id="w" name="wake" type="checkbox" style="width:auto"> May wake your other devices: hand an agent an event through ${esc(user)}</label>
|
|
146
159
|
<button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
|
|
160
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quo-systems/dock",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
71
|
-
"@quo-systems/quo": "^0.2.
|
|
71
|
+
"@quo-systems/quo": "^0.2.2",
|
|
72
72
|
"esbuild": "^0.28.2",
|
|
73
73
|
"ws": "^8.21.3"
|
|
74
74
|
},
|