@quo-systems/dock 0.1.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/README.md +9 -2
- 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 +185 -38
- package/beings/setup.ts +3 -1
- package/beings/side.ts +10 -1
- package/beings/user.ts +31 -5
- package/cli/daemon.ts +98 -44
- package/cli/estate/Caddyfile +25 -0
- package/cli/estate/quo.service +36 -0
- package/cli/estate.ts +44 -0
- package/cli/quo.ts +14 -3
- 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/setup.js +4 -1
- 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/estate/Caddyfile +25 -0
- package/dist/cli/estate/quo.service +36 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +46 -0
- package/dist/cli/quo.js +14 -3
- package/dist/harbor/edge/exercise.js +3 -1
- package/dist/harbor/edge/platform.d.ts +28 -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 +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 +11 -4
- package/dist/mcp/oauth.js +32 -21
- package/dist/mcp/pilot.d.ts +3 -4
- package/dist/mcp/pilot.js +19 -65
- package/dist/mcp/server.d.ts +6 -4
- package/dist/mcp/server.js +48 -11
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/edge/exercise.ts +2 -1
- 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 +39 -23
- package/mcp/pilot.ts +26 -65
- package/mcp/quo-mcp.md +34 -19
- package/mcp/server.ts +52 -19
- package/mcp/web/exchange.ts +23 -9
- package/package.json +7 -3
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
|
@@ -28,13 +28,18 @@ export const CODE_TTL = 10 * 60 * 1000; // a code, and a pending request, live t
|
|
|
28
28
|
export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
|
|
29
29
|
export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// A client lives ten minutes from registration, the life of a request, and
|
|
32
|
+
// as long as its refresh token once the human allowed it: the register door
|
|
33
|
+
// is open to anyone, and what nobody allowed must not stay on disk.
|
|
34
|
+
export type Client = { client_id: string; client_name: string; redirect_uris: string[]; exp: number };
|
|
32
35
|
export type Pending = { client_id: string; redirect_uri: string; challenge: string; state: string | null; resource: string | null; exp: number };
|
|
33
|
-
|
|
36
|
+
// 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 };
|
|
34
39
|
export type Store = {
|
|
35
40
|
clients: Record<string, Client>;
|
|
36
41
|
pending: Record<string, Pending>;
|
|
37
|
-
codes: Record<string, Pending & { identity: string }>;
|
|
42
|
+
codes: Record<string, Pending & { identity: string; ward: string }>;
|
|
38
43
|
access: Record<string, Grant>;
|
|
39
44
|
refresh: Record<string, Grant>;
|
|
40
45
|
};
|
|
@@ -87,16 +92,24 @@ export class OAuth {
|
|
|
87
92
|
const b = body as { client_name?: unknown; redirect_uris?: unknown };
|
|
88
93
|
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
|
|
89
94
|
if (uris.length === 0) return { error: 'invalid_redirect_uri' };
|
|
90
|
-
const client: Client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
|
|
95
|
+
const client: Client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris, exp: this.now() + CODE_TTL };
|
|
91
96
|
this.store.clients[client.client_id] = client;
|
|
97
|
+
this.sweep();
|
|
92
98
|
await this.o.persist(this.store);
|
|
93
|
-
|
|
99
|
+
const { exp: _, ...shown } = client;
|
|
100
|
+
return shown as Client;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A client still alive, by id.
|
|
104
|
+
client(id: string): Client | undefined {
|
|
105
|
+
const c = own(this.store.clients, id);
|
|
106
|
+
return c && c.exp >= this.now() ? c : undefined;
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
// Start a request. What comes back is where to send the browser: the web
|
|
97
110
|
// route with the request id, or the client's redirect with an error.
|
|
98
111
|
async authorize(q: URLSearchParams): Promise<{ redirect: string } | { error: string }> {
|
|
99
|
-
const client =
|
|
112
|
+
const client = this.client(q.get('client_id') ?? '');
|
|
100
113
|
const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
|
|
101
114
|
if (!client || redirect === null || !client.redirect_uris.includes(redirect)) return { error: 'invalid_client' };
|
|
102
115
|
const back = (error: string) => ({ redirect: withQuery(redirect, { error, state: q.get('state') }) });
|
|
@@ -114,17 +127,18 @@ export class OAuth {
|
|
|
114
127
|
pending(id: string): (Pending & { client: Client }) | null {
|
|
115
128
|
const p = own(this.store.pending, id);
|
|
116
129
|
if (!p || p.exp < this.now()) return null;
|
|
117
|
-
const client =
|
|
130
|
+
const client = this.client(p.client_id);
|
|
118
131
|
return client ? { ...p, client } : null;
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
// The web route's last step: the human allowed this client as this
|
|
122
|
-
|
|
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 }> {
|
|
123
137
|
const p = this.pending(id);
|
|
124
138
|
if (!p) return { error: 'expired' };
|
|
125
139
|
delete this.store.pending[id];
|
|
126
140
|
const code = token();
|
|
127
|
-
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
141
|
+
this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
|
|
128
142
|
await this.o.persist(this.store);
|
|
129
143
|
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
130
144
|
}
|
|
@@ -149,46 +163,48 @@ export class OAuth {
|
|
|
149
163
|
await this.o.persist(this.store);
|
|
150
164
|
return { error: 'invalid_grant' };
|
|
151
165
|
}
|
|
152
|
-
return this.issue(c.identity, c.client_id);
|
|
166
|
+
return this.issue(c.identity, c.ward ?? 'main', c.client_id);
|
|
153
167
|
}
|
|
154
168
|
if (grant === 'refresh_token') {
|
|
155
169
|
const rt = body.get('refresh_token') ?? '';
|
|
156
170
|
const r = own(this.store.refresh, rt);
|
|
157
171
|
if (!r || r.exp < this.now()) return { error: 'invalid_grant' };
|
|
158
172
|
delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
|
|
159
|
-
return this.issue(r.identity, r.client_id);
|
|
173
|
+
return this.issue(r.identity, r.ward ?? 'main', r.client_id);
|
|
160
174
|
}
|
|
161
175
|
return { error: 'unsupported_grant_type' };
|
|
162
176
|
}
|
|
163
177
|
|
|
164
|
-
async issue(identity: string, client_id: string) {
|
|
178
|
+
async issue(identity: string, ward: string, client_id: string) {
|
|
165
179
|
const access_token = token(),
|
|
166
180
|
refresh_token = token();
|
|
167
|
-
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
168
|
-
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 };
|
|
183
|
+
const c = own(this.store.clients, client_id);
|
|
184
|
+
if (c) c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
169
185
|
this.sweep();
|
|
170
186
|
await this.o.persist(this.store);
|
|
171
187
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
172
188
|
}
|
|
173
189
|
|
|
174
|
-
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
175
|
-
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 {
|
|
176
192
|
const h = req.headers.authorization ?? '';
|
|
177
193
|
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
178
194
|
const g = own(this.store.access, t);
|
|
179
|
-
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;
|
|
180
196
|
}
|
|
181
197
|
|
|
182
|
-
// Every grant an identity holds, gone: the route's half of
|
|
183
|
-
// The other half is the user being removing the occupant.
|
|
184
|
-
async revoke(identity: string): Promise<void> {
|
|
185
|
-
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];
|
|
186
202
|
await this.o.persist(this.store);
|
|
187
203
|
}
|
|
188
204
|
|
|
189
205
|
sweep() {
|
|
190
206
|
const now = this.now();
|
|
191
|
-
for (const k of ['pending', 'codes', 'access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.exp
|
|
207
|
+
for (const k of ['clients', 'pending', 'codes', 'access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (!(g.exp >= now)) delete this.store[k][t]; // a record with no exp is from before there was one, and goes too
|
|
192
208
|
}
|
|
193
209
|
|
|
194
210
|
// The HTTP face. `rest` is the path under the route.
|
package/mcp/pilot.ts
CHANGED
|
@@ -1,73 +1,34 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The owner pilot: `quo pilot`.
|
|
3
|
-
// ward
|
|
4
|
-
// and
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// places; the work goes through
|
|
9
|
-
|
|
2
|
+
// The owner pilot: `quo pilot`. The model side pointed at a ward instead of
|
|
3
|
+
// an avatar. The ward is a being to her owner, so her describe is the tool
|
|
4
|
+
// list and a tool call is an owner ask, exactly as for anyone; nothing here
|
|
5
|
+
// names an ask. Whoever runs this process is the owner, by the device's own
|
|
6
|
+
// rules: the root of the ward here, through the daemon's socket, or, with
|
|
7
|
+
// `via`, an owner at another ward's door, on a standing the user being here
|
|
8
|
+
// holds there. The owner creates and places; the work goes through
|
|
9
|
+
// `quo side`, under a gate.
|
|
10
10
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
11
|
-
import {
|
|
11
|
+
import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
|
|
12
12
|
import { ask } from '../cli/client.ts';
|
|
13
13
|
import type { Serving } from '../beings/side.ts';
|
|
14
|
+
import { mcpSide, type Subject } from './server.ts';
|
|
14
15
|
|
|
15
|
-
export const PILOT_TOOLS: Tool[] = [
|
|
16
|
-
{
|
|
17
|
-
name: 'census',
|
|
18
|
-
description: 'the empty ask: the ward pk and every being, with class, public and digest',
|
|
19
|
-
inputSchema: { type: 'object', properties: {} },
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
name: 'boot',
|
|
23
|
-
description: 'boot a being by class name under a key; public marks the one public being',
|
|
24
|
-
inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
name: 'invite',
|
|
28
|
-
description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
|
|
29
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: 'knock',
|
|
33
|
-
description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
|
|
34
|
-
inputSchema: {
|
|
35
|
-
type: 'object',
|
|
36
|
-
properties: {
|
|
37
|
-
being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
|
|
38
|
-
id: { type: 'string' },
|
|
39
|
-
invitation: { type: 'object' },
|
|
40
|
-
method: { type: 'string' },
|
|
41
|
-
args: { type: 'object' },
|
|
42
|
-
wanted: { type: 'object', properties: { time: { type: 'number' } } },
|
|
43
|
-
},
|
|
44
|
-
required: ['being', 'id', 'invitation'],
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
name: 'remove',
|
|
49
|
-
description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
|
|
50
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
51
|
-
},
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
export type Log = (line: string) => void;
|
|
55
16
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
56
17
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return
|
|
18
|
+
// The owner hears objects: the socket's own failure, a silence and a word
|
|
19
|
+
// each come back as an error object named for what it was.
|
|
20
|
+
export function owner(dir: string, ward: string, via?: string): Subject {
|
|
21
|
+
const one = async (method: string | undefined, args: JsonObject): Promise<Answer> => {
|
|
22
|
+
const out = await ask(dir, method, args, ward, via);
|
|
23
|
+
if ('error' in out) return { error: out.error };
|
|
24
|
+
const r = out.result;
|
|
25
|
+
if (isRecord(r) && r.silence === true) return { error: 'silence' };
|
|
26
|
+
if (isRecord(r) && typeof r.word === 'string') return { error: r.word };
|
|
27
|
+
return r as Answer;
|
|
28
|
+
};
|
|
29
|
+
return { tools: () => one(undefined, {}) as Promise<Blueprint | { error: string }>, call: (name, args) => one(name, args ?? {}), ears: new Set() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving> {
|
|
33
|
+
return mcpSide(owner(dir, ward, via), transport);
|
|
73
34
|
}
|
package/mcp/quo-mcp.md
CHANGED
|
@@ -23,19 +23,32 @@ and needs no translation code beyond an envelope:
|
|
|
23
23
|
| server | an avatar's side |
|
|
24
24
|
| session | a standing the avatar holds, persistent across sessions |
|
|
25
25
|
| tools/list | the empty ask on that standing: describe for this asker |
|
|
26
|
+
| the `describe` tool | the empty ask itself, first in the list, so the notes of the |
|
|
27
|
+
| | describe are readable and not only its asks |
|
|
26
28
|
| tool name, inputSchema | `asks[].name`, `asks[].input`, verbatim |
|
|
27
29
|
| tools/call | a named ask with args |
|
|
28
30
|
| result content | the answer object, as JSON |
|
|
29
31
|
| tool error result | an error object she answered, `{ error }`, as JSON |
|
|
30
32
|
| protocol error | silence and the ward's words, see the table below |
|
|
31
33
|
| auth token | an invitation, used once, then keys |
|
|
32
|
-
|
|
|
34
|
+
| tool title, annotations | her `look`: a title and the hints per ask, see below |
|
|
35
|
+
| resources, prompts | not mapped. |
|
|
33
36
|
|
|
34
37
|
A tool list is a describe. Because a being describes per asker, two models
|
|
35
38
|
connected to the same user being see two different tool lists, and neither
|
|
36
39
|
can call what it cannot see: the gate is one decision for describe and for
|
|
37
40
|
dispatch.
|
|
38
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
|
+
|
|
39
52
|
The three words for "no object" cross the envelope like this:
|
|
40
53
|
|
|
41
54
|
| the avatar heard | the client gets |
|
|
@@ -54,6 +67,11 @@ the two words give it what it needs to decide.
|
|
|
54
67
|
|
|
55
68
|
### A remote MCP client connects
|
|
56
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
|
+
|
|
57
75
|
```
|
|
58
76
|
client mcp. route front desk user being avatar
|
|
59
77
|
|-- OAuth ------->| | | |
|
|
@@ -198,23 +216,20 @@ the device and crosses only the local socket.
|
|
|
198
216
|
|
|
199
217
|
### A model as the owner
|
|
200
218
|
|
|
201
|
-
An **owner pilot** is
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
own rules. A remote pilot is a standing at the ward, and still not a route:
|
|
216
|
-
a model without a ward of its own cannot be an owner anywhere. `quo pilot
|
|
217
|
-
--via S` is that pilot: S is a standing the user being here holds at the
|
|
219
|
+
An **owner pilot** is the model side above, pointed at a ward instead of an
|
|
220
|
+
avatar. The ward is a being to her owner, and `packages/quo/SPEC.md` says her
|
|
221
|
+
describe carries her asks with a description and an input each, so the
|
|
222
|
+
pilot holds no list of its own: tools/list is the ward's describe, the
|
|
223
|
+
`describe` tool is the census, and a tool call is an owner ask, boot,
|
|
224
|
+
public, invite, knock, remove or unboot, in the ward's own words. It reaches those
|
|
225
|
+
asks either as the root through the daemon's socket on the device, or as an
|
|
226
|
+
owner the root invited, through a standing over the sealed door. It keeps
|
|
227
|
+
nothing and logs nothing: what a model did with it is in the host's own
|
|
228
|
+
transcript, and an invitation it was handed is in no file of the dock's.
|
|
229
|
+
The pilot is handed to one local agent by the device's own rules. A remote
|
|
230
|
+
pilot is a standing at the ward, and still not a route: a model without a
|
|
231
|
+
ward of its own cannot be an owner anywhere. `quo pilot --via S` is that
|
|
232
|
+
pilot: S is a standing the user being here holds at the
|
|
218
233
|
far ward, taken when the root here knocked for her with an invitation the
|
|
219
234
|
far root minted on its ward's pk, and every tool call is a sealed ask
|
|
220
235
|
there, answered as the far ward answers an owner at its door.
|
|
@@ -259,4 +274,4 @@ On top of the shared ones in the trunk:
|
|
|
259
274
|
under; governance, never permission.
|
|
260
275
|
- **runner**: an inline MCP client for a model that speaks function calling;
|
|
261
276
|
it drives the loop the MCP client would, over one conversation.
|
|
262
|
-
- **owner pilot**:
|
|
277
|
+
- **owner pilot**: the model side over a ward's owner asks, over stdio.
|
package/mcp/server.ts
CHANGED
|
@@ -1,44 +1,77 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The model side as an MCP server: one
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// The model side as an MCP server: one subject, one transport. The subject
|
|
3
|
+
// is whoever answers the empty ask and a named one: an avatar on her one
|
|
4
|
+
// standing, or a ward's owner asks through the pilot. The mapping is total
|
|
5
|
+
// and needs nothing beyond this envelope. tools/list is her describe, and
|
|
6
|
+
// the empty ask itself is the first tool, so the notes of the describe are
|
|
7
|
+
// readable; tools/call is a named ask, a push is a logging notification,
|
|
8
|
+
// and the three words for "no object" each cross as the table in
|
|
9
|
+
// quo-mcp.md says. The transport is whatever the caller connected: the
|
|
7
10
|
// SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
|
|
8
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
12
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
10
13
|
import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
11
14
|
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
12
|
-
import type
|
|
13
|
-
import {
|
|
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';
|
|
14
19
|
|
|
15
20
|
export const NAME = 'quo';
|
|
16
21
|
export const VERSION = '0.0.0';
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
export type { Subject } from '../beings/side.ts';
|
|
24
|
+
|
|
25
|
+
// The empty ask as a tool: her describe whole, asks and notes.
|
|
26
|
+
export const DESCRIBE: Tool = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
27
|
+
|
|
28
|
+
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
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
|
+
});
|
|
48
|
+
return [DESCRIBE, ...asks];
|
|
27
49
|
}
|
|
28
50
|
|
|
29
51
|
// `after` runs when a call is done: a harbor that must write what the ward
|
|
30
52
|
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
31
|
-
export async function mcpSide(avatar:
|
|
53
|
+
export async function mcpSide(avatar: Subject, transport: Transport, after: () => Promise<void> = async () => {}): Promise<Serving> {
|
|
32
54
|
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
33
55
|
|
|
56
|
+
// Her look, asked once per digest of her describe.
|
|
57
|
+
let seen: string | null = null;
|
|
58
|
+
let look: Look = {};
|
|
34
59
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
35
60
|
const bp = await avatar.tools();
|
|
36
61
|
if ('error' in bp && !('asks' in bp)) return { tools: [] }; // not joined: nothing to show, and nothing to call
|
|
37
|
-
|
|
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) };
|
|
38
71
|
});
|
|
39
72
|
|
|
40
73
|
server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
|
|
41
|
-
const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {}) as JsonObject));
|
|
74
|
+
const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {}) as JsonObject));
|
|
42
75
|
await after();
|
|
43
76
|
if (w.word === 'object') {
|
|
44
77
|
const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
|
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.1
|
|
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",
|
|
@@ -54,17 +54,21 @@
|
|
|
54
54
|
"./human/html": {
|
|
55
55
|
"types": "./dist/human/html.d.ts",
|
|
56
56
|
"default": "./dist/human/html.js"
|
|
57
|
+
},
|
|
58
|
+
"./harbor/edge/worker": {
|
|
59
|
+
"types": "./dist/harbor/edge/worker.d.ts",
|
|
60
|
+
"default": "./dist/harbor/edge/worker.js"
|
|
57
61
|
}
|
|
58
62
|
},
|
|
59
63
|
"scripts": {
|
|
60
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
64
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp harbor/edge/platform.d.ts dist/harbor/edge/ && cp -R cli/estate dist/cli/",
|
|
61
65
|
"test": "node --test \"test/*.test.ts\"",
|
|
62
66
|
"check:terrain": "node --test \"test/terrain/*.test.ts\"",
|
|
63
67
|
"prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"
|
|
64
68
|
},
|
|
65
69
|
"dependencies": {
|
|
66
70
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
67
|
-
"@quo-systems/quo": "^0.2.
|
|
71
|
+
"@quo-systems/quo": "^0.2.1",
|
|
68
72
|
"esbuild": "^0.28.2",
|
|
69
73
|
"ws": "^8.21.3"
|
|
70
74
|
},
|