@quo-systems/dock 0.2.0 → 0.2.1
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 +6 -2
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +2 -0
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +129 -19
- package/beings/side.ts +10 -1
- package/beings/user.ts +31 -5
- package/cli/daemon.ts +98 -44
- package/cli/quo.ts +3 -2
- package/dist/beings/avatar.js +7 -2
- 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 +2 -0
- 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 +26 -2
- package/dist/beings/user.js +32 -5
- package/dist/cli/daemon.d.ts +1 -1
- package/dist/cli/daemon.js +98 -44
- package/dist/cli/quo.js +3 -2
- 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 +25 -6
- package/dist/human/tab.d.ts +2 -0
- package/dist/human/tab.js +127 -42
- 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/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 +37 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +123 -66
- package/human/screen.ts +28 -7
- package/human/tab.ts +153 -51
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +18 -2
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/human/tab.ts
CHANGED
|
@@ -1,23 +1,58 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The tab: a page
|
|
3
|
-
// on a seed minted into IndexedDB the first time,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// socket with it, joins the user being, and from then on there is no cookie
|
|
8
|
-
// and no token anywhere. The second time the page opens, she already holds
|
|
9
|
-
// `user` and nothing is asked.
|
|
2
|
+
// The tab: a page on a world's web. that is a device of its own. The tab
|
|
3
|
+
// boots a browser harbor on a seed minted into IndexedDB the first time,
|
|
4
|
+
// and keeps one ward per world it has joined, each with one avatar, so the
|
|
5
|
+
// tab is a browser of worlds: a switcher between them is a switch of ward
|
|
6
|
+
// and nothing more. It dials the world's quo. route.
|
|
10
7
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// page
|
|
14
|
-
|
|
8
|
+
// Three ways in, all the same act, a knock that ends in a standing:
|
|
9
|
+
//
|
|
10
|
+
// a link the page with the invitation in its fragment, `#quo=...`,
|
|
11
|
+
// read and stripped before anything else runs, then joined.
|
|
12
|
+
// a guest the world's public being, whatever class she is, rendered
|
|
13
|
+
// as a page by the same screen; a form on it whose answer is
|
|
14
|
+
// an invitation is the way in.
|
|
15
|
+
// a password the dock's own way, when the public being shows `device`:
|
|
16
|
+
// the owner password goes to the web route for a nonce the
|
|
17
|
+
// front desk honours under the `tab` proof kind, and the
|
|
18
|
+
// guest form `device({ proof })` is submitted for the human.
|
|
19
|
+
//
|
|
20
|
+
// From then on there is no cookie and no token anywhere; the seed and the
|
|
21
|
+
// standing are in the tab's store, and a reload asks nothing. The screen
|
|
22
|
+
// is the human side in `screen.ts` over the DOM surface in `dom.ts`. This
|
|
23
|
+
// file is the harbor, the worlds and the ways in, and hands the avatar over.
|
|
24
|
+
import type { Invitation, JsonObject } from '@quo-systems/quo';
|
|
15
25
|
import { BrowserHarbor } from '../harbor/browser.ts';
|
|
16
26
|
import { Avatar, USER } from '../beings/avatar.ts';
|
|
27
|
+
import { parse, strip, isInvitation } from '../beings/link.ts';
|
|
17
28
|
import { screenSide } from './screen.ts';
|
|
18
29
|
import { domSurface } from './dom.ts';
|
|
30
|
+
import { guest } from './guest.ts';
|
|
31
|
+
import { escape } from './html.ts';
|
|
32
|
+
|
|
33
|
+
// What the page is told by the daemon that served it: the world's routes,
|
|
34
|
+
// this ward's name on that harbor, and its pk.
|
|
35
|
+
export type Config = { quo: string; web: string; ward: string; pk: string };
|
|
19
36
|
|
|
20
|
-
|
|
37
|
+
// The worlds this tab has joined, by pk: where each lives and what it is
|
|
38
|
+
// called, kept beside the harbor so the switcher can list them. A world on
|
|
39
|
+
// another origin is another page, and the switcher links to it.
|
|
40
|
+
type Worlds = Record<string, { url: string; name: string }>;
|
|
41
|
+
const WORLDS = 'quo-worlds';
|
|
42
|
+
const worlds = (): Worlds => {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(localStorage.getItem(WORLDS) ?? '{}') as Worlds;
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const remember = (pk: string, url: string, name: string) => {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(WORLDS, JSON.stringify({ ...worlds(), [pk]: { url, name } }));
|
|
52
|
+
} catch {
|
|
53
|
+
/* a tab with no storage still works, and forgets */
|
|
54
|
+
}
|
|
55
|
+
};
|
|
21
56
|
|
|
22
57
|
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
|
|
23
58
|
const e = document.createElement(tag);
|
|
@@ -27,58 +62,125 @@ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Rec
|
|
|
27
62
|
};
|
|
28
63
|
|
|
29
64
|
export async function start(cfg: Config, root: HTMLElement = document.body): Promise<void> {
|
|
65
|
+
// The link, first and once: the invitation leaves the address bar before
|
|
66
|
+
// any other code sees it.
|
|
67
|
+
const linked = parse(location.hash);
|
|
68
|
+
if (linked) history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
|
|
69
|
+
|
|
70
|
+
const nav = el('nav', '', { class: 'worlds' });
|
|
30
71
|
const status = el('p', 'booting');
|
|
31
72
|
const screen = el('div');
|
|
32
|
-
root.append(status, screen);
|
|
73
|
+
root.append(nav, status, screen);
|
|
33
74
|
const say = (s: string) => (status.textContent = s);
|
|
34
75
|
|
|
35
|
-
// The harbor in the tab: one database
|
|
76
|
+
// The harbor in the tab: one database per origin, one ward per world.
|
|
36
77
|
const harbor = new BrowserHarbor('quo');
|
|
37
78
|
await harbor.boot();
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
79
|
+
const name = `w-${cfg.pk.slice(0, 16)}`;
|
|
80
|
+
const ward = harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
|
|
81
|
+
if (!ward.being('me')) await ward.ask('boot', { key: 'me', class: 'Avatar' });
|
|
82
|
+
const me = ward.being('me') as Avatar;
|
|
41
83
|
harbor.dial(cfg.quo);
|
|
42
84
|
|
|
43
|
-
|
|
85
|
+
const switcher = () => {
|
|
86
|
+
nav.replaceChildren();
|
|
87
|
+
const known = worlds();
|
|
88
|
+
for (const [pk, w] of Object.entries(known)) {
|
|
89
|
+
const a = el('a', w.name || pk.slice(0, 8), { href: w.url });
|
|
90
|
+
if (pk === cfg.pk) a.setAttribute('aria-current', 'page');
|
|
91
|
+
nav.append(a);
|
|
92
|
+
}
|
|
93
|
+
const here = worlds()[cfg.pk];
|
|
94
|
+
if (!here) nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
|
|
95
|
+
};
|
|
96
|
+
switcher();
|
|
97
|
+
|
|
98
|
+
// Joined: her page. Every call rotates her keys and a same-ward ask never
|
|
44
99
|
// crosses the harbor, so the side saves after each one.
|
|
45
|
-
const
|
|
100
|
+
const inside = async (notice: string) => {
|
|
46
101
|
status.remove();
|
|
47
|
-
|
|
102
|
+
screen.replaceChildren();
|
|
103
|
+
const side = await screenSide(me, domSurface(screen), { after: () => ward.save(), notice });
|
|
104
|
+
remember(cfg.pk, location.origin + location.pathname, side.model.look.name || (typeof (side.model.blueprint?.notes as JsonObject | null)?.name === 'string' ? ((side.model.blueprint!.notes as JsonObject).name as string) : cfg.ward));
|
|
105
|
+
switcher();
|
|
48
106
|
};
|
|
49
107
|
|
|
50
|
-
//
|
|
51
|
-
const
|
|
52
|
-
if ('asks' in had) return show('in, as before');
|
|
53
|
-
|
|
54
|
-
// The exchange, once: the owner password for a nonce, the nonce for an
|
|
55
|
-
// invitation the user being mints, the invitation for a standing.
|
|
56
|
-
const form = el('form');
|
|
57
|
-
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
58
|
-
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
59
|
-
const go = el('button', 'enter');
|
|
60
|
-
form.append(identity, password, go);
|
|
61
|
-
root.insertBefore(form, screen);
|
|
62
|
-
say(`not in (${had.error}): the owner password opens the world`);
|
|
63
|
-
form.onsubmit = async (ev) => {
|
|
64
|
-
ev.preventDefault();
|
|
65
|
-
say('asking the world');
|
|
66
|
-
let res: Response;
|
|
67
|
-
try {
|
|
68
|
-
res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
69
|
-
} catch (e) {
|
|
70
|
-
return say(`the world did not answer: ${(e as Error).message}`);
|
|
71
|
-
}
|
|
72
|
-
if (!res.ok) return say(`refused: ${(await res.json().catch(() => ({ error: res.status })) as { error?: string }).error ?? res.status}`);
|
|
73
|
-
const { nonce, ward } = (await res.json()) as { nonce: string; ward: string };
|
|
74
|
-
password.value = '';
|
|
108
|
+
// The way in, from any of the three: join, save, and be inside.
|
|
109
|
+
const admit = async (inv: Invitation, notice: string) => {
|
|
75
110
|
say('knocking');
|
|
76
|
-
const got = await me.
|
|
77
|
-
await
|
|
78
|
-
if (!('asks' in got))
|
|
79
|
-
|
|
80
|
-
|
|
111
|
+
const got = await me.join(inv);
|
|
112
|
+
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
113
|
+
if (!('asks' in got)) {
|
|
114
|
+
say(`not in: ${got.error}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await inside(notice);
|
|
81
118
|
};
|
|
119
|
+
|
|
120
|
+
// Already in: reconnect, nothing minted.
|
|
121
|
+
if (me.standings[USER]) {
|
|
122
|
+
const had = await me.tools();
|
|
123
|
+
if ('asks' in had) return inside('in, as before');
|
|
124
|
+
say(`not in (${had.error})`);
|
|
125
|
+
}
|
|
126
|
+
if (linked) return admit(linked, 'in, by the link');
|
|
127
|
+
|
|
128
|
+
// A guest: the public being's describe as a page; a form whose answer is
|
|
129
|
+
// an invitation lets the guest in.
|
|
130
|
+
say(`at the door of ${cfg.ward}`);
|
|
131
|
+
const door = guest(me, cfg.pk);
|
|
132
|
+
const side = await screenSide(door, domSurface(screen), {
|
|
133
|
+
after: () => ward.save(),
|
|
134
|
+
notice: `a guest at ${cfg.ward}: what she shows strangers`,
|
|
135
|
+
admit: async (inv) => {
|
|
136
|
+
await side.close();
|
|
137
|
+
await admit(inv, `in, as a guest of ${cfg.ward}`);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The dock's own way in, when the desk is at the door: the owner password
|
|
142
|
+
// for a nonce, and the desk's `device` form submitted with it.
|
|
143
|
+
if (side.model.blueprint?.asks.some((a) => a.name === 'device')) {
|
|
144
|
+
const form = el('form', '', { class: 'password' });
|
|
145
|
+
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
146
|
+
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
147
|
+
const go = el('button', 'enter as the owner');
|
|
148
|
+
form.append(identity, password, go);
|
|
149
|
+
root.insertBefore(form, screen);
|
|
150
|
+
form.onsubmit = (ev) => {
|
|
151
|
+
ev.preventDefault();
|
|
152
|
+
void (async () => {
|
|
153
|
+
say('asking the world');
|
|
154
|
+
let res: Response;
|
|
155
|
+
try {
|
|
156
|
+
res = await fetch(`${cfg.web}/${encodeURIComponent(cfg.ward)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
say(`the world did not answer: ${(e as Error).message}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
password.value = '';
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
say(`refused: ${((await res.json().catch(() => ({ error: res.status }))) as { error?: string }).error ?? res.status}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const { nonce } = (await res.json()) as { nonce: string };
|
|
167
|
+
const inv = await door.call('device', { proof: { kind: 'tab', nonce } });
|
|
168
|
+
await ward.save();
|
|
169
|
+
if (!isInvitation(inv)) {
|
|
170
|
+
say(`not in: ${escape(JSON.stringify(inv))}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
form.remove();
|
|
174
|
+
await side.close();
|
|
175
|
+
await admit(inv, `in, as ${identity.value}`);
|
|
176
|
+
})();
|
|
177
|
+
};
|
|
178
|
+
}
|
|
82
179
|
}
|
|
83
180
|
|
|
181
|
+
// The page hands the config in a JSON script, which a content security
|
|
182
|
+
// policy allows where an inline script is not.
|
|
183
|
+
const config = document.getElementById('quo');
|
|
184
|
+
if (config?.textContent) void start(JSON.parse(config.textContent) as Config);
|
|
185
|
+
|
|
84
186
|
export { USER };
|
package/mcp/http.ts
CHANGED
|
@@ -15,9 +15,10 @@ import type { Avatar } from '../beings/avatar.ts';
|
|
|
15
15
|
import type { Serving } from '../beings/side.ts';
|
|
16
16
|
import { mcpSide } from './server.ts';
|
|
17
17
|
|
|
18
|
-
export type Session = { identity: string; transport: StreamableHTTPServerTransport; serving: Serving; touched: number };
|
|
18
|
+
export type Session = { identity: string; ward: string; transport: StreamableHTTPServerTransport; serving: Serving; touched: number };
|
|
19
19
|
export const SESSION_IDLE = 60 * 60 * 1000;
|
|
20
|
-
|
|
20
|
+
// An identity in a world: one harbor holds many, and the grant names which.
|
|
21
|
+
export type Resolve = (identity: string, ward: string) => Promise<{ avatar?: Avatar; error?: string }>;
|
|
21
22
|
|
|
22
23
|
export class McpHttp {
|
|
23
24
|
readonly sessions = new Map<string, Session>();
|
|
@@ -27,7 +28,7 @@ export class McpHttp {
|
|
|
27
28
|
// What to do with an identity the user being has removed: the route's
|
|
28
29
|
// revoke, so the client's tokens go with the occupant. Set by whoever
|
|
29
30
|
// mounts the route beside the credential exchange.
|
|
30
|
-
gone: (identity: string) => Promise<void> = async () => {};
|
|
31
|
+
gone: (identity: string, ward: string) => Promise<void> = async () => {};
|
|
31
32
|
constructor(resolve: Resolve, after: () => Promise<void> = async () => {}) {
|
|
32
33
|
this.resolve = resolve;
|
|
33
34
|
this.after = after;
|
|
@@ -36,7 +37,7 @@ export class McpHttp {
|
|
|
36
37
|
// `identity` is what the bearer named; the caller has already turned a
|
|
37
38
|
// stranger away. A request on a known session goes to it. A request with
|
|
38
39
|
// no session opens one, if it is an initialize; anything else is 400.
|
|
39
|
-
async handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void> {
|
|
40
|
+
async handle(req: IncomingMessage, res: ServerResponse, identity: string, ward = 'main'): Promise<void> {
|
|
40
41
|
const id = req.headers['mcp-session-id'];
|
|
41
42
|
const sid = Array.isArray(id) ? id[0] : id;
|
|
42
43
|
const json = (status: number, body: unknown) => {
|
|
@@ -47,22 +48,22 @@ export class McpHttp {
|
|
|
47
48
|
if (sid !== undefined) {
|
|
48
49
|
const s = this.sessions.get(sid);
|
|
49
50
|
if (!s) return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
|
|
50
|
-
if (s.identity !== identity) return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
51
|
+
if (s.identity !== identity || s.ward !== ward) return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
51
52
|
s.touched = this.now();
|
|
52
53
|
await s.transport.handleRequest(req, res);
|
|
53
54
|
if (req.method === 'DELETE') this.drop(sid);
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
if (req.method !== 'POST') return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
|
|
57
|
-
const found = await this.resolve(identity);
|
|
58
|
+
const found = await this.resolve(identity, ward);
|
|
58
59
|
// A session opens on her describe, and admit already asked it. The one
|
|
59
60
|
// word the door says for an identity the user being removed is
|
|
60
61
|
// `removed`, under the key it bound for her avatar: in MCP's vocabulary
|
|
61
62
|
// that is 401, the client drops its token and starts the exchange again,
|
|
62
63
|
// and the route forgets the grant.
|
|
63
64
|
if (found.error === 'removed') {
|
|
64
|
-
await this.gone(identity);
|
|
65
|
-
for (const [sid, s] of this.sessions) if (s.identity === identity) this.drop(sid);
|
|
65
|
+
await this.gone(identity, ward);
|
|
66
|
+
for (const [sid, s] of this.sessions) if (s.identity === identity && s.ward === ward) this.drop(sid);
|
|
66
67
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
67
68
|
return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
|
|
68
69
|
}
|
|
@@ -70,7 +71,7 @@ export class McpHttp {
|
|
|
70
71
|
const transport = new StreamableHTTPServerTransport({
|
|
71
72
|
sessionIdGenerator: () => randomUUID(),
|
|
72
73
|
onsessioninitialized: (s) => {
|
|
73
|
-
this.sessions.set(s, { identity, transport, serving, touched: this.now() });
|
|
74
|
+
this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
|
|
74
75
|
},
|
|
75
76
|
onsessionclosed: (s) => this.drop(s),
|
|
76
77
|
});
|
package/mcp/oauth.ts
CHANGED
|
@@ -33,11 +33,13 @@ export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty
|
|
|
33
33
|
// is open to anyone, and what nobody allowed must not stay on disk.
|
|
34
34
|
export type Client = { client_id: string; client_name: string; redirect_uris: string[]; exp: number };
|
|
35
35
|
export type Pending = { client_id: string; redirect_uri: string; challenge: string; state: string | null; resource: string | null; exp: number };
|
|
36
|
-
|
|
36
|
+
// A grant names the world the human allowed the client into, since one
|
|
37
|
+
// harbor holds many; a record from before there were worlds is `main`'s.
|
|
38
|
+
export type Grant = { identity: string; ward: string; client_id: string; exp: number };
|
|
37
39
|
export type Store = {
|
|
38
40
|
clients: Record<string, Client>;
|
|
39
41
|
pending: Record<string, Pending>;
|
|
40
|
-
codes: Record<string, Pending & { identity: string }>;
|
|
42
|
+
codes: Record<string, Pending & { identity: string; ward: string }>;
|
|
41
43
|
access: Record<string, Grant>;
|
|
42
44
|
refresh: Record<string, Grant>;
|
|
43
45
|
};
|
|
@@ -129,13 +131,14 @@ export class OAuth {
|
|
|
129
131
|
return client ? { ...p, client } : null;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
// The web route's last step: the human allowed this client as this
|
|
133
|
-
|
|
134
|
+
// The web route's last step: the human allowed this client as this
|
|
135
|
+
// identity, into this world.
|
|
136
|
+
async complete(id: string, identity: string, ward = 'main'): Promise<{ redirect: string } | { error: string }> {
|
|
134
137
|
const p = this.pending(id);
|
|
135
138
|
if (!p) return { error: 'expired' };
|
|
136
139
|
delete this.store.pending[id];
|
|
137
140
|
const code = token();
|
|
138
|
-
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
141
|
+
this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
|
|
139
142
|
await this.o.persist(this.store);
|
|
140
143
|
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
141
144
|
}
|
|
@@ -160,23 +163,23 @@ export class OAuth {
|
|
|
160
163
|
await this.o.persist(this.store);
|
|
161
164
|
return { error: 'invalid_grant' };
|
|
162
165
|
}
|
|
163
|
-
return this.issue(c.identity, c.client_id);
|
|
166
|
+
return this.issue(c.identity, c.ward ?? 'main', c.client_id);
|
|
164
167
|
}
|
|
165
168
|
if (grant === 'refresh_token') {
|
|
166
169
|
const rt = body.get('refresh_token') ?? '';
|
|
167
170
|
const r = own(this.store.refresh, rt);
|
|
168
171
|
if (!r || r.exp < this.now()) return { error: 'invalid_grant' };
|
|
169
172
|
delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
|
|
170
|
-
return this.issue(r.identity, r.client_id);
|
|
173
|
+
return this.issue(r.identity, r.ward ?? 'main', r.client_id);
|
|
171
174
|
}
|
|
172
175
|
return { error: 'unsupported_grant_type' };
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
async issue(identity: string, client_id: string) {
|
|
178
|
+
async issue(identity: string, ward: string, client_id: string) {
|
|
176
179
|
const access_token = token(),
|
|
177
180
|
refresh_token = token();
|
|
178
|
-
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
179
|
-
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
181
|
+
this.store.access[access_token] = { identity, ward, client_id, exp: this.now() + ACCESS_TTL };
|
|
182
|
+
this.store.refresh[refresh_token] = { identity, ward, client_id, exp: this.now() + REFRESH_TTL };
|
|
180
183
|
const c = own(this.store.clients, client_id);
|
|
181
184
|
if (c) c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
182
185
|
this.sweep();
|
|
@@ -184,18 +187,18 @@ export class OAuth {
|
|
|
184
187
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
188
|
-
bearer(req: IncomingMessage): string | null {
|
|
190
|
+
// The bearer on an MCP request, to a client identity in a world. Null is 401.
|
|
191
|
+
bearer(req: IncomingMessage): { identity: string; ward: string } | null {
|
|
189
192
|
const h = req.headers.authorization ?? '';
|
|
190
193
|
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
191
194
|
const g = own(this.store.access, t);
|
|
192
|
-
return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
|
|
195
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward ?? 'main' } : null;
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
// Every grant an identity holds, gone: the route's half of
|
|
196
|
-
// The other half is the user being removing the occupant.
|
|
197
|
-
async revoke(identity: string): Promise<void> {
|
|
198
|
-
for (const k of ['access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.identity === identity) delete this.store[k][t];
|
|
198
|
+
// Every grant an identity holds in a world, gone: the route's half of
|
|
199
|
+
// revocation. The other half is the user being removing the occupant.
|
|
200
|
+
async revoke(identity: string, ward = 'main'): Promise<void> {
|
|
201
|
+
for (const k of ['access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.identity === identity && (g.ward ?? 'main') === ward) delete this.store[k][t];
|
|
199
202
|
await this.o.persist(this.store);
|
|
200
203
|
}
|
|
201
204
|
|
package/mcp/pilot.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function owner(dir: string, ward: string, via?: string): Subject {
|
|
|
26
26
|
if (isRecord(r) && typeof r.word === 'string') return { error: r.word };
|
|
27
27
|
return r as Answer;
|
|
28
28
|
};
|
|
29
|
-
return { tools: () => one(undefined, {}) as Promise<Blueprint | { error: string }>, call: (name, args) => one(name, args), ears: new Set() };
|
|
29
|
+
return { tools: () => one(undefined, {}) as Promise<Blueprint | { error: string }>, call: (name, args) => one(name, args ?? {}), ears: new Set() };
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving> {
|
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,11 @@ 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.
|
|
74
|
+
|
|
59
75
|
```
|
|
60
76
|
client mcp. route front desk user being avatar
|
|
61
77
|
|-- OAuth ------->| | | |
|
|
@@ -205,7 +221,7 @@ avatar. The ward is a being to her owner, and `packages/quo/SPEC.md` says her
|
|
|
205
221
|
describe carries her asks with a description and an input each, so the
|
|
206
222
|
pilot holds no list of its own: tools/list is the ward's describe, the
|
|
207
223
|
`describe` tool is the census, and a tool call is an owner ask, boot,
|
|
208
|
-
public, invite, knock or
|
|
224
|
+
public, invite, knock, remove or unboot, in the ward's own words. It reaches those
|
|
209
225
|
asks either as the root through the daemon's socket on the device, or as an
|
|
210
226
|
owner the root invited, through a standing over the sealed door. It keeps
|
|
211
227
|
nothing and logs nothing: what a model did with it is in the host's own
|
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.1",
|
|
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.1",
|
|
72
72
|
"esbuild": "^0.28.2",
|
|
73
73
|
"ws": "^8.21.3"
|
|
74
74
|
},
|