@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/dist/mcp/http.d.ts
CHANGED
|
@@ -4,12 +4,13 @@ import type { Avatar } from '../beings/avatar.ts';
|
|
|
4
4
|
import type { Serving } from '../beings/side.ts';
|
|
5
5
|
export type Session = {
|
|
6
6
|
identity: string;
|
|
7
|
+
ward: string;
|
|
7
8
|
transport: StreamableHTTPServerTransport;
|
|
8
9
|
serving: Serving;
|
|
9
10
|
touched: number;
|
|
10
11
|
};
|
|
11
12
|
export declare const SESSION_IDLE: number;
|
|
12
|
-
export type Resolve = (identity: string) => Promise<{
|
|
13
|
+
export type Resolve = (identity: string, ward: string) => Promise<{
|
|
13
14
|
avatar?: Avatar;
|
|
14
15
|
error?: string;
|
|
15
16
|
}>;
|
|
@@ -18,9 +19,9 @@ export declare class McpHttp {
|
|
|
18
19
|
readonly resolve: Resolve;
|
|
19
20
|
readonly after: () => Promise<void>;
|
|
20
21
|
now: () => number;
|
|
21
|
-
gone: (identity: string) => Promise<void>;
|
|
22
|
+
gone: (identity: string, ward: string) => Promise<void>;
|
|
22
23
|
constructor(resolve: Resolve, after?: () => Promise<void>);
|
|
23
|
-
handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void>;
|
|
24
|
+
handle(req: IncomingMessage, res: ServerResponse, identity: string, ward?: string): Promise<void>;
|
|
24
25
|
sweep(): void;
|
|
25
26
|
drop(sid: string): void;
|
|
26
27
|
close(): Promise<void>;
|
package/dist/mcp/http.js
CHANGED
|
@@ -28,7 +28,7 @@ export class McpHttp {
|
|
|
28
28
|
// `identity` is what the bearer named; the caller has already turned a
|
|
29
29
|
// stranger away. A request on a known session goes to it. A request with
|
|
30
30
|
// no session opens one, if it is an initialize; anything else is 400.
|
|
31
|
-
async handle(req, res, identity) {
|
|
31
|
+
async handle(req, res, identity, ward = 'main') {
|
|
32
32
|
const id = req.headers['mcp-session-id'];
|
|
33
33
|
const sid = Array.isArray(id) ? id[0] : id;
|
|
34
34
|
const json = (status, body) => {
|
|
@@ -40,7 +40,7 @@ export class McpHttp {
|
|
|
40
40
|
const s = this.sessions.get(sid);
|
|
41
41
|
if (!s)
|
|
42
42
|
return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
|
|
43
|
-
if (s.identity !== identity)
|
|
43
|
+
if (s.identity !== identity || s.ward !== ward)
|
|
44
44
|
return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
45
45
|
s.touched = this.now();
|
|
46
46
|
await s.transport.handleRequest(req, res);
|
|
@@ -50,16 +50,16 @@ export class McpHttp {
|
|
|
50
50
|
}
|
|
51
51
|
if (req.method !== 'POST')
|
|
52
52
|
return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
|
|
53
|
-
const found = await this.resolve(identity);
|
|
53
|
+
const found = await this.resolve(identity, ward);
|
|
54
54
|
// A session opens on her describe, and admit already asked it. The one
|
|
55
55
|
// word the door says for an identity the user being removed is
|
|
56
56
|
// `removed`, under the key it bound for her avatar: in MCP's vocabulary
|
|
57
57
|
// that is 401, the client drops its token and starts the exchange again,
|
|
58
58
|
// and the route forgets the grant.
|
|
59
59
|
if (found.error === 'removed') {
|
|
60
|
-
await this.gone(identity);
|
|
60
|
+
await this.gone(identity, ward);
|
|
61
61
|
for (const [sid, s] of this.sessions)
|
|
62
|
-
if (s.identity === identity)
|
|
62
|
+
if (s.identity === identity && s.ward === ward)
|
|
63
63
|
this.drop(sid);
|
|
64
64
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
65
65
|
return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
|
|
@@ -69,7 +69,7 @@ export class McpHttp {
|
|
|
69
69
|
const transport = new StreamableHTTPServerTransport({
|
|
70
70
|
sessionIdGenerator: () => randomUUID(),
|
|
71
71
|
onsessioninitialized: (s) => {
|
|
72
|
-
this.sessions.set(s, { identity, transport, serving, touched: this.now() });
|
|
72
|
+
this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
|
|
73
73
|
},
|
|
74
74
|
onsessionclosed: (s) => this.drop(s),
|
|
75
75
|
});
|
package/dist/mcp/oauth.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type Pending = {
|
|
|
18
18
|
};
|
|
19
19
|
export type Grant = {
|
|
20
20
|
identity: string;
|
|
21
|
+
ward: string;
|
|
21
22
|
client_id: string;
|
|
22
23
|
exp: number;
|
|
23
24
|
};
|
|
@@ -26,6 +27,7 @@ export type Store = {
|
|
|
26
27
|
pending: Record<string, Pending>;
|
|
27
28
|
codes: Record<string, Pending & {
|
|
28
29
|
identity: string;
|
|
30
|
+
ward: string;
|
|
29
31
|
}>;
|
|
30
32
|
access: Record<string, Grant>;
|
|
31
33
|
refresh: Record<string, Grant>;
|
|
@@ -72,7 +74,7 @@ export declare class OAuth {
|
|
|
72
74
|
pending(id: string): (Pending & {
|
|
73
75
|
client: Client;
|
|
74
76
|
}) | null;
|
|
75
|
-
complete(id: string, identity: string): Promise<{
|
|
77
|
+
complete(id: string, identity: string, ward?: string): Promise<{
|
|
76
78
|
redirect: string;
|
|
77
79
|
} | {
|
|
78
80
|
error: string;
|
|
@@ -83,15 +85,18 @@ export declare class OAuth {
|
|
|
83
85
|
error: string;
|
|
84
86
|
}>;
|
|
85
87
|
token(body: URLSearchParams): Promise<Record<string, unknown>>;
|
|
86
|
-
issue(identity: string, client_id: string): Promise<{
|
|
88
|
+
issue(identity: string, ward: string, client_id: string): Promise<{
|
|
87
89
|
access_token: string;
|
|
88
90
|
token_type: string;
|
|
89
91
|
expires_in: number;
|
|
90
92
|
refresh_token: string;
|
|
91
93
|
scope: string;
|
|
92
94
|
}>;
|
|
93
|
-
bearer(req: IncomingMessage):
|
|
94
|
-
|
|
95
|
+
bearer(req: IncomingMessage): {
|
|
96
|
+
identity: string;
|
|
97
|
+
ward: string;
|
|
98
|
+
} | null;
|
|
99
|
+
revoke(identity: string, ward?: string): Promise<void>;
|
|
95
100
|
sweep(): void;
|
|
96
101
|
handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
|
|
97
102
|
challenge(res: ServerResponse): void;
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -99,14 +99,15 @@ export class OAuth {
|
|
|
99
99
|
const client = this.client(p.client_id);
|
|
100
100
|
return client ? { ...p, client } : null;
|
|
101
101
|
}
|
|
102
|
-
// The web route's last step: the human allowed this client as this
|
|
103
|
-
|
|
102
|
+
// The web route's last step: the human allowed this client as this
|
|
103
|
+
// identity, into this world.
|
|
104
|
+
async complete(id, identity, ward = 'main') {
|
|
104
105
|
const p = this.pending(id);
|
|
105
106
|
if (!p)
|
|
106
107
|
return { error: 'expired' };
|
|
107
108
|
delete this.store.pending[id];
|
|
108
109
|
const code = token();
|
|
109
|
-
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
110
|
+
this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
|
|
110
111
|
await this.o.persist(this.store);
|
|
111
112
|
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
112
113
|
}
|
|
@@ -131,7 +132,7 @@ export class OAuth {
|
|
|
131
132
|
await this.o.persist(this.store);
|
|
132
133
|
return { error: 'invalid_grant' };
|
|
133
134
|
}
|
|
134
|
-
return this.issue(c.identity, c.client_id);
|
|
135
|
+
return this.issue(c.identity, c.ward ?? 'main', c.client_id);
|
|
135
136
|
}
|
|
136
137
|
if (grant === 'refresh_token') {
|
|
137
138
|
const rt = body.get('refresh_token') ?? '';
|
|
@@ -139,14 +140,14 @@ export class OAuth {
|
|
|
139
140
|
if (!r || r.exp < this.now())
|
|
140
141
|
return { error: 'invalid_grant' };
|
|
141
142
|
delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
|
|
142
|
-
return this.issue(r.identity, r.client_id);
|
|
143
|
+
return this.issue(r.identity, r.ward ?? 'main', r.client_id);
|
|
143
144
|
}
|
|
144
145
|
return { error: 'unsupported_grant_type' };
|
|
145
146
|
}
|
|
146
|
-
async issue(identity, client_id) {
|
|
147
|
+
async issue(identity, ward, client_id) {
|
|
147
148
|
const access_token = token(), refresh_token = token();
|
|
148
|
-
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
149
|
-
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
149
|
+
this.store.access[access_token] = { identity, ward, client_id, exp: this.now() + ACCESS_TTL };
|
|
150
|
+
this.store.refresh[refresh_token] = { identity, ward, client_id, exp: this.now() + REFRESH_TTL };
|
|
150
151
|
const c = own(this.store.clients, client_id);
|
|
151
152
|
if (c)
|
|
152
153
|
c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
@@ -154,19 +155,19 @@ export class OAuth {
|
|
|
154
155
|
await this.o.persist(this.store);
|
|
155
156
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
156
157
|
}
|
|
157
|
-
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
158
|
+
// The bearer on an MCP request, to a client identity in a world. Null is 401.
|
|
158
159
|
bearer(req) {
|
|
159
160
|
const h = req.headers.authorization ?? '';
|
|
160
161
|
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
161
162
|
const g = own(this.store.access, t);
|
|
162
|
-
return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
|
|
163
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward ?? 'main' } : null;
|
|
163
164
|
}
|
|
164
|
-
// Every grant an identity holds, gone: the route's half of
|
|
165
|
-
// The other half is the user being removing the occupant.
|
|
166
|
-
async revoke(identity) {
|
|
165
|
+
// Every grant an identity holds in a world, gone: the route's half of
|
|
166
|
+
// revocation. The other half is the user being removing the occupant.
|
|
167
|
+
async revoke(identity, ward = 'main') {
|
|
167
168
|
for (const k of ['access', 'refresh'])
|
|
168
169
|
for (const [t, g] of Object.entries(this.store[k]))
|
|
169
|
-
if (g.identity === identity)
|
|
170
|
+
if (g.identity === identity && (g.ward ?? 'main') === ward)
|
|
170
171
|
delete this.store[k][t];
|
|
171
172
|
await this.o.persist(this.store);
|
|
172
173
|
}
|
package/dist/mcp/pilot.js
CHANGED
|
@@ -15,7 +15,7 @@ export function owner(dir, ward, via) {
|
|
|
15
15
|
return { error: r.word };
|
|
16
16
|
return r;
|
|
17
17
|
};
|
|
18
|
-
return { tools: () => one(undefined, {}), call: (name, args) => one(name, args), ears: new Set() };
|
|
18
|
+
return { tools: () => one(undefined, {}), call: (name, args) => one(name, args ?? {}), ears: new Set() };
|
|
19
19
|
}
|
|
20
20
|
export function pilotSide(dir, ward, transport, via) {
|
|
21
21
|
return mcpSide(owner(dir, ward, via), transport);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { OAuth } from './oauth.ts';
|
|
2
|
+
import type { McpHttp } from './http.ts';
|
|
3
|
+
import type { Handler } from '../cli/http.ts';
|
|
4
|
+
export type Routes = {
|
|
5
|
+
mcp: string;
|
|
6
|
+
web: string;
|
|
7
|
+
quo?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null): Promise<{
|
|
10
|
+
oauth: OAuth;
|
|
11
|
+
handler: Handler;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,34 @@
|
|
|
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 } from './oauth.js';
|
|
12
|
+
export async function mcpRoute(dir, routes, mcp) {
|
|
13
|
+
const file = join(dir, 'oauth.json');
|
|
14
|
+
const store = existsSync(file) ? { ...emptyStore(), ...JSON.parse(await readFile(file, 'utf8')) } : emptyStore();
|
|
15
|
+
let queue = Promise.resolve();
|
|
16
|
+
const persist = (s) => (queue = queue.then(async () => {
|
|
17
|
+
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
18
|
+
await rename(file + '.tmp', file);
|
|
19
|
+
}));
|
|
20
|
+
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
21
|
+
const handler = async (req, res, rest) => {
|
|
22
|
+
if (await oauth.handle(req, res, rest))
|
|
23
|
+
return;
|
|
24
|
+
if (rest === '/mcp') {
|
|
25
|
+
const grant = oauth.bearer(req);
|
|
26
|
+
if (grant === null || !mcp)
|
|
27
|
+
return oauth.challenge(res);
|
|
28
|
+
return mcp.handle(req, res, grant.identity, grant.ward);
|
|
29
|
+
}
|
|
30
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
31
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
32
|
+
};
|
|
33
|
+
return { oauth, handler };
|
|
34
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
2
|
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import type {
|
|
4
|
-
import { type Serving } from '../beings/side.ts';
|
|
3
|
+
import type { Blueprint } from '@quo-systems/quo';
|
|
4
|
+
import { type Serving, type Subject } from '../beings/side.ts';
|
|
5
|
+
import { type Look } from '../beings/look.ts';
|
|
5
6
|
export declare const NAME = "quo";
|
|
6
7
|
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
|
-
};
|
|
8
|
+
export type { Subject } from '../beings/side.ts';
|
|
14
9
|
export declare const DESCRIBE: Tool;
|
|
15
|
-
export declare function tools(bp: Blueprint): Tool[];
|
|
10
|
+
export declare function tools(bp: Blueprint, look?: Look): Tool[];
|
|
16
11
|
export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -11,19 +11,37 @@
|
|
|
11
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
12
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
13
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
14
|
+
import { hintFor, sanitise } from '../beings/look.js';
|
|
15
|
+
import { isSilence, isWord, digest } from '@quo-systems/quo';
|
|
14
16
|
export const NAME = 'quo';
|
|
15
17
|
export const VERSION = '0.0.0';
|
|
16
18
|
// The empty ask as a tool: her describe whole, asks and notes.
|
|
17
19
|
export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
18
20
|
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
19
|
-
// input are verbatim; an output schema crosses when she declared one.
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// input are verbatim; an output schema crosses when she declared one. Her
|
|
22
|
+
// look, when she has one, is the hints: a title, and the annotations a host
|
|
23
|
+
// reads. The `look` ask itself is presentation, and is not a tool.
|
|
24
|
+
export function tools(bp, look = {}) {
|
|
25
|
+
const asks = bp.asks
|
|
26
|
+
.filter((a) => a.name !== 'look')
|
|
27
|
+
.map((a) => {
|
|
22
28
|
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
23
29
|
if (a.description !== undefined)
|
|
24
30
|
t.description = a.description;
|
|
25
31
|
if (a.output !== undefined)
|
|
26
32
|
t.outputSchema = { ...a.output, type: 'object' };
|
|
33
|
+
const h = hintFor(bp, look, a.name);
|
|
34
|
+
if (h.title !== undefined)
|
|
35
|
+
t.title = h.title;
|
|
36
|
+
const notes = {};
|
|
37
|
+
if (h.readOnly !== undefined)
|
|
38
|
+
notes.readOnlyHint = h.readOnly;
|
|
39
|
+
if (h.destructive !== undefined)
|
|
40
|
+
notes.destructiveHint = h.destructive;
|
|
41
|
+
if (h.idempotent !== undefined)
|
|
42
|
+
notes.idempotentHint = h.idempotent;
|
|
43
|
+
if (Object.keys(notes).length)
|
|
44
|
+
t.annotations = notes;
|
|
27
45
|
return t;
|
|
28
46
|
});
|
|
29
47
|
return [DESCRIBE, ...asks];
|
|
@@ -32,11 +50,24 @@ export function tools(bp) {
|
|
|
32
50
|
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
33
51
|
export async function mcpSide(avatar, transport, after = async () => { }) {
|
|
34
52
|
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
53
|
+
// Her look, asked once per digest of her describe.
|
|
54
|
+
let seen = null;
|
|
55
|
+
let look = {};
|
|
35
56
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
36
57
|
const bp = await avatar.tools();
|
|
37
58
|
if ('error' in bp && !('asks' in bp))
|
|
38
59
|
return { tools: [] }; // not joined: nothing to show, and nothing to call
|
|
39
|
-
|
|
60
|
+
const d = await digest(bp);
|
|
61
|
+
if (d !== seen) {
|
|
62
|
+
seen = d;
|
|
63
|
+
if (bp.asks.some((a) => a.name === 'look')) {
|
|
64
|
+
const l = await avatar.call('look', {});
|
|
65
|
+
look = isSilence(l) || isWord(l) ? {} : sanitise(l);
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
look = {};
|
|
69
|
+
}
|
|
70
|
+
return { tools: tools(bp, look) };
|
|
40
71
|
});
|
|
41
72
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
42
73
|
const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { OAuth } from '../oauth.ts';
|
|
3
3
|
export declare const SESSION_TTL: number;
|
|
4
|
-
export type Admit = (identity: string, wake: boolean) => Promise<{
|
|
4
|
+
export type Admit = (identity: string, wake: boolean, reach: boolean, ward: string) => Promise<{
|
|
5
5
|
error?: string;
|
|
6
6
|
}>;
|
|
7
7
|
export type Options = {
|
|
8
8
|
oauth: OAuth;
|
|
9
9
|
password: () => string | undefined;
|
|
10
10
|
admit: Admit;
|
|
11
|
-
|
|
11
|
+
worlds: () => {
|
|
12
|
+
ward: string;
|
|
13
|
+
user: string;
|
|
14
|
+
}[];
|
|
12
15
|
now?: () => number;
|
|
13
16
|
};
|
|
14
17
|
export declare const suggest: (name: string) => string;
|
package/dist/mcp/web/exchange.js
CHANGED
|
@@ -90,7 +90,7 @@ export class Exchange {
|
|
|
90
90
|
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
91
91
|
if (!this.valid(req))
|
|
92
92
|
return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
93
|
-
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.
|
|
93
|
+
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds())), true;
|
|
94
94
|
}
|
|
95
95
|
if (rest === '/allow' && req.method === 'POST') {
|
|
96
96
|
const f = await readForm(req);
|
|
@@ -104,13 +104,17 @@ export class Exchange {
|
|
|
104
104
|
const out = await this.o.oauth.deny(request);
|
|
105
105
|
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`), true;
|
|
106
106
|
}
|
|
107
|
+
const worlds = this.o.worlds();
|
|
108
|
+
const world = worlds.find((w) => w.ward === (f.get('ward') ?? worlds[0]?.ward));
|
|
109
|
+
if (!world)
|
|
110
|
+
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;
|
|
107
111
|
const identity = word(f.get('identity'));
|
|
108
|
-
if (identity === null || identity ===
|
|
109
|
-
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name),
|
|
110
|
-
const admitted = await this.o.admit(identity, f.get('wake') === 'on');
|
|
112
|
+
if (identity === null || identity === world.user || identity === 'desk')
|
|
113
|
+
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;
|
|
114
|
+
const admitted = await this.o.admit(identity, f.get('wake') === 'on', f.get('reach') === 'on', world.ward);
|
|
111
115
|
if (admitted.error)
|
|
112
116
|
return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
|
|
113
|
-
const out = await this.o.oauth.complete(request, identity);
|
|
117
|
+
const out = await this.o.oauth.complete(request, identity, world.ward);
|
|
114
118
|
return 'redirect' in out ? go(out.redirect, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
|
|
115
119
|
}
|
|
116
120
|
return false;
|
|
@@ -125,12 +129,22 @@ ${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
|
125
129
|
<form method="post" action="/login"><input type="hidden" name="request" value="${esc(request)}">
|
|
126
130
|
<label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
127
131
|
<button type="submit">Log in</button></form>`;
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
// The world is a choice when the harbor has more than one; the user named
|
|
133
|
+
// is the first world's, and the page says which world each identity lands in.
|
|
134
|
+
const allowForm = (request, client, redirect, identity, worlds, err = '') => {
|
|
135
|
+
const user = worlds[0]?.user ?? '';
|
|
136
|
+
const pick = worlds.length > 1
|
|
137
|
+
? `<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>`
|
|
138
|
+
: `<input type="hidden" name="ward" value="${esc(worlds[0]?.ward ?? 'main')}">`;
|
|
139
|
+
return `<h1>Allow ${esc(client)}?</h1>
|
|
140
|
+
<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>
|
|
130
141
|
<p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
|
|
131
142
|
<p>It returns to <code>${esc(redirect)}</code>.</p></div>
|
|
132
143
|
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
133
144
|
<form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
|
|
145
|
+
${pick}
|
|
134
146
|
<label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
|
|
147
|
+
<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>
|
|
135
148
|
<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>
|
|
136
149
|
<button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
|
|
150
|
+
};
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -88,6 +88,38 @@ the new harbor pointed at the same code. Same seed, same pk, every standing
|
|
|
88
88
|
anyone holds still points at her. The old harbor deletes its copies first:
|
|
89
89
|
two harbors holding one seed are two wards with one pk, diverging silently.
|
|
90
90
|
|
|
91
|
+
What a move costs is not the three parts, which are bytes, but how many
|
|
92
|
+
peers wrote down where the ward was. A pk is permanent by arithmetic; a
|
|
93
|
+
location is soft state in every other harbor's directory, and only that goes
|
|
94
|
+
stale. So the bill is proportional to who hard-coded it, and there are two
|
|
95
|
+
kinds of ward.
|
|
96
|
+
|
|
97
|
+
A ward whose harbor dials out is free. It announces what it holds when a
|
|
98
|
+
line opens and whenever that changes, the listener proves each claim at the
|
|
99
|
+
door and binds it, and a ward that arrives on a new box is reachable as soon
|
|
100
|
+
as its harbor dials. Nobody is told and no peer acts.
|
|
101
|
+
|
|
102
|
+
A ward that is dialed keeps a second name. Peers hold a hint, pk at a URL,
|
|
103
|
+
in their own stores and across their own restarts, so that URL is a promise
|
|
104
|
+
the same way the pk is. Moving boxes does not break it, because a hostname
|
|
105
|
+
is not a location: DNS and the proxy point it wherever the harbor now runs,
|
|
106
|
+
which is the whole reason the route is a hostname and not an address.
|
|
107
|
+
Changing the URL itself is the move that costs, and it cannot be announced
|
|
108
|
+
to a peer this ward cannot reach.
|
|
109
|
+
|
|
110
|
+
When it must change, what is republished is a hint and never an invitation.
|
|
111
|
+
The relation, its keys and its count are untouched, and the invitation is
|
|
112
|
+
as good as it was: that is why the two travel separately, a link being one
|
|
113
|
+
next to the other. Peers on a rendezvous this ward also dials relearn by
|
|
114
|
+
themselves, since a binding is proven at the door and names no URL; peers
|
|
115
|
+
holding a hint need a new one, and a hint is not a capability, so it may be
|
|
116
|
+
published in the open to all of them at once. Nobody is invited twice.
|
|
117
|
+
|
|
118
|
+
The rule, then: a dialable ward keeps two names and owes the second the same
|
|
119
|
+
care as the first, and a ward that only dials keeps one, which is why a
|
|
120
|
+
laptop, a tab and a phone move home without ceremony and the droplet does
|
|
121
|
+
not.
|
|
122
|
+
|
|
91
123
|
### Code is a harbor decision
|
|
92
124
|
|
|
93
125
|
A ward's code has an origin: a folder the owner wrote, a bundle a world
|
|
@@ -168,14 +200,17 @@ rotation, and nothing else.
|
|
|
168
200
|
|
|
169
201
|
## Reach, directory, rendezvous
|
|
170
202
|
|
|
171
|
-
A harbor needs
|
|
203
|
+
A harbor needs two reach kinds and nothing more, which is what
|
|
204
|
+
`packages/quo/SPEC.md` says and what `packages/quo/src/harbor/reach.ts` is:
|
|
172
205
|
|
|
173
206
|
- **request**: a URL. Send bytes, get bytes. Listener to listener.
|
|
174
207
|
- **socket**: a held connection, opened by whichever side can dial, used in
|
|
175
208
|
both directions with a frame id. A WebSocket, a WebRTC channel and a
|
|
176
209
|
native socket are one socket reach.
|
|
177
|
-
|
|
178
|
-
|
|
210
|
+
|
|
211
|
+
Two runtimes on one device are no third kind. Either they are one harbor,
|
|
212
|
+
and it calls its other door with no transport at all, or they are two
|
|
213
|
+
harbors, and one of them dials the other as any dialer does.
|
|
179
214
|
|
|
180
215
|
Plus one forwarding rule: a pk whose reach is another harbor that holds a
|
|
181
216
|
socket to it. That rule is the **rendezvous**, and with it every dialer is
|
|
@@ -228,13 +263,19 @@ rebinds it.
|
|
|
228
263
|
The reach interface and its two implementations are the library's,
|
|
229
264
|
`packages/quo/src/harbor/reach.ts`, and pass its reach suite here: the request
|
|
230
265
|
and a socket to a daemon in `packages/dock/test/wire.test.ts`, the request
|
|
231
|
-
through the worker inside the edge. The listener half is the terrain's,
|
|
266
|
+
through the worker inside the edge. The listener half is the terrain's,
|
|
267
|
+
`packages/dock/harbor/quo.ts`, the route the daemon mounts under `/quo`:
|
|
232
268
|
|
|
233
269
|
```
|
|
234
270
|
request POST <url>/<pk>, the sealed ask as the body, the sealed reply as the answer;
|
|
235
271
|
<url> is the world's quo. route, which the proxy maps onto the daemon's /quo
|
|
236
272
|
404 is "no reach for that pk" and comes back as nothing; a line lost after
|
|
237
273
|
sending answers nothing at all, and the ward's bound ends the ask
|
|
274
|
+
at most 1 MiB is read, and a body over it is answered by that same 404: an
|
|
275
|
+
ask too big for this door is nothing delivered, never a silence, so it is
|
|
276
|
+
safe to send again and it will fail again. The number is this daemon's and
|
|
277
|
+
is on no wire; a caller cannot ask for it and is never told which of the
|
|
278
|
+
two a 404 was
|
|
238
279
|
socket a WebSocket at <url>, held by the dialer, used both ways
|
|
239
280
|
text frame { announce: [pk, ...] } the ward pks this side holds
|
|
240
281
|
ask frame [0][id 4][pk 64][bytes] a frame id matches the reply
|
|
@@ -252,19 +293,18 @@ request. So a third harbor with a hint that a phone's pk is at acme's
|
|
|
252
293
|
`quo serve --dial URL`, or `<dir>/dial.json`, makes a daemon a dialer: it
|
|
253
294
|
holds one socket per URL, announces its wards on open, binds the listener's
|
|
254
295
|
pks, and reconnects with backoff from a second to thirty when the line
|
|
255
|
-
drops. A pk is 64 bytes on the wire, the ward key and the padlock
|
|
256
|
-
first version of the ask frame got wrong and the suite caught in one run.
|
|
296
|
+
drops. A pk is 64 bytes on the wire, the ward key and the padlock.
|
|
257
297
|
|
|
258
298
|
The proof is `packages/dock/test/wire.test.ts`: the library's conformance suite,
|
|
259
299
|
untouched, against two disk harbors in two folders with HTTP doors on
|
|
260
300
|
loopback, once with every ask a request and once with the second harbor
|
|
261
301
|
dialing the first; down is the reach refused, migrate is the ward folder
|
|
262
|
-
moved and adopted. Then the rendezvous on its own.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
302
|
+
moved and adopted. Then the rendezvous on its own.
|
|
303
|
+
|
|
304
|
+
Two rules the daemon obeys and a reader would otherwise rediscover: a harbor
|
|
305
|
+
writes the partition before it lets a folder go, because a being driven
|
|
306
|
+
in-process changes it without passing a door; and a proxy sends the route
|
|
307
|
+
root as `/quo/`, with the trailing slash, which the door takes.
|
|
268
308
|
|
|
269
309
|
## The two cores, as built
|
|
270
310
|
|
|
@@ -301,7 +341,7 @@ harbors in one tab with two databases, both dialing a Node daemon on
|
|
|
301
341
|
loopback, so every relation crosses the rendezvous; down is the reach
|
|
302
342
|
refused on both, migrate lifts seed, partition and record from one database
|
|
303
343
|
into the other and boots there, the tab-to-home scene. And the lock: a
|
|
304
|
-
second harbor over one database does not boot.
|
|
344
|
+
second harbor over one database does not boot.
|
|
305
345
|
|
|
306
346
|
## The edge, as built
|
|
307
347
|
|
|
@@ -333,11 +373,10 @@ against two edge harbors in one object over two prefixed views of its
|
|
|
333
373
|
storage, reaching each other by request through the worker; the root door
|
|
334
374
|
refused without the secret and answering under it; and a Node daemon
|
|
335
375
|
dialing the edge, its pk held there, reached by a third harbor through the
|
|
336
|
-
edge, unreached when the line drops.
|
|
337
|
-
touch another's storage,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
which wants a plain one.
|
|
376
|
+
edge, unreached when the line drops. Two platform rules shape it: one object
|
|
377
|
+
may not touch another's storage, so the exercise's two harbors share one
|
|
378
|
+
object the way two tabs share a page; and the platform's crypto refuses a
|
|
379
|
+
Uint8Array over a shared buffer and wants a plain one.
|
|
341
380
|
|
|
342
381
|
The `ws` question the road asked is answered by this terrain: the socket
|
|
343
382
|
reach's framing and the dialer are written on the standard surface and
|
|
@@ -362,10 +401,10 @@ device
|
|
|
362
401
|
quo.sock owner asks; local only; never behind a proxy
|
|
363
402
|
side.sock local sides; local only; never behind a proxy
|
|
364
403
|
127.0.0.1:8787 the HTTP door, loopback only; routes mount by path
|
|
365
|
-
/health the wards it hosts, by name and pk
|
|
366
|
-
/mcp the model side
|
|
367
|
-
/web the
|
|
368
|
-
/quo
|
|
404
|
+
/health the wards it hosts, by name and pk; cli/http.ts
|
|
405
|
+
/mcp the model side; mcp/route.ts
|
|
406
|
+
/web the worlds' pages and the exchange; human/web.ts, mcp/web/
|
|
407
|
+
/quo the socket door: request in, sockets held, the rendezvous; harbor/quo.ts
|
|
369
408
|
reverse proxy
|
|
370
409
|
mcp.example.com -> 127.0.0.1:8787/mcp
|
|
371
410
|
web.example.com -> 127.0.0.1:8787/web
|
|
@@ -389,15 +428,13 @@ There is no `cli.` route.
|
|
|
389
428
|
|
|
390
429
|
## Acceptance
|
|
391
430
|
|
|
392
|
-
|
|
393
|
-
untouched, and by nothing else.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
day. Reach, store and the core now hold on three terrains, which is where the
|
|
397
|
-
library takes them. Every store passes the library's store suite,
|
|
431
|
+
A harbor here is accepted when it passes the library's conformance suite
|
|
432
|
+
untouched, and by nothing else. Three do: the disk harbor over the wire
|
|
433
|
+
under both reaches, the browser harbor through a daemon, and the edge harbor
|
|
434
|
+
inside the platform's runtime. Every store passes the library's store suite,
|
|
398
435
|
`packages/quo/src/conformance/store.ts`: files in
|
|
399
436
|
`packages/dock/test/store.test.ts`, IndexedDB and the edge's storage inside
|
|
400
437
|
their terrains behind `check:terrain`. Every reach passes the library's reach
|
|
401
438
|
suite, `packages/quo/src/conformance/reach.ts`. The store, the reach and the
|
|
402
|
-
core are
|
|
403
|
-
|
|
439
|
+
core are the library's, and every harbor here is one of them over its own
|
|
440
|
+
terrain.
|