@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.
Files changed (72) hide show
  1. package/README.md +9 -2
  2. package/beings/avatar.ts +6 -2
  3. package/beings/carry.ts +101 -0
  4. package/beings/desk.ts +2 -2
  5. package/beings/index.ts +2 -0
  6. package/beings/link.ts +54 -0
  7. package/beings/look.ts +96 -0
  8. package/beings/quo-dock.md +185 -38
  9. package/beings/setup.ts +3 -1
  10. package/beings/side.ts +10 -1
  11. package/beings/user.ts +31 -5
  12. package/cli/daemon.ts +98 -44
  13. package/cli/estate/Caddyfile +25 -0
  14. package/cli/estate/quo.service +36 -0
  15. package/cli/estate.ts +44 -0
  16. package/cli/quo.ts +14 -3
  17. package/dist/beings/avatar.js +7 -2
  18. package/dist/beings/carry.d.ts +10 -0
  19. package/dist/beings/carry.js +106 -0
  20. package/dist/beings/desk.d.ts +1 -0
  21. package/dist/beings/desk.js +1 -1
  22. package/dist/beings/index.d.ts +2 -0
  23. package/dist/beings/index.js +2 -0
  24. package/dist/beings/link.d.ts +7 -0
  25. package/dist/beings/link.js +42 -0
  26. package/dist/beings/look.d.ts +27 -0
  27. package/dist/beings/look.js +71 -0
  28. package/dist/beings/setup.js +4 -1
  29. package/dist/beings/side.d.ts +8 -1
  30. package/dist/beings/user.d.ts +26 -2
  31. package/dist/beings/user.js +32 -5
  32. package/dist/cli/daemon.d.ts +1 -1
  33. package/dist/cli/daemon.js +98 -44
  34. package/dist/cli/estate/Caddyfile +25 -0
  35. package/dist/cli/estate/quo.service +36 -0
  36. package/dist/cli/estate.d.ts +5 -0
  37. package/dist/cli/estate.js +46 -0
  38. package/dist/cli/quo.js +14 -3
  39. package/dist/harbor/edge/exercise.js +3 -1
  40. package/dist/harbor/edge/platform.d.ts +28 -0
  41. package/dist/human/guest.d.ts +3 -0
  42. package/dist/human/guest.js +25 -0
  43. package/dist/human/html.d.ts +10 -2
  44. package/dist/human/html.js +61 -10
  45. package/dist/human/screen.d.ts +8 -3
  46. package/dist/human/screen.js +25 -6
  47. package/dist/human/tab.d.ts +2 -0
  48. package/dist/human/tab.js +127 -42
  49. package/dist/mcp/http.d.ts +4 -3
  50. package/dist/mcp/http.js +6 -6
  51. package/dist/mcp/oauth.d.ts +11 -4
  52. package/dist/mcp/oauth.js +32 -21
  53. package/dist/mcp/pilot.d.ts +3 -4
  54. package/dist/mcp/pilot.js +19 -65
  55. package/dist/mcp/server.d.ts +6 -4
  56. package/dist/mcp/server.js +48 -11
  57. package/dist/mcp/web/exchange.d.ts +5 -2
  58. package/dist/mcp/web/exchange.js +21 -7
  59. package/harbor/edge/exercise.ts +2 -1
  60. package/harbor/quo-harbor.md +37 -0
  61. package/human/guest.ts +26 -0
  62. package/human/html.ts +58 -10
  63. package/human/quo-human.md +123 -66
  64. package/human/screen.ts +28 -7
  65. package/human/tab.ts +153 -51
  66. package/mcp/http.ts +10 -9
  67. package/mcp/oauth.ts +39 -23
  68. package/mcp/pilot.ts +26 -65
  69. package/mcp/quo-mcp.md +34 -19
  70. package/mcp/server.ts +52 -19
  71. package/mcp/web/exchange.ts +23 -9
  72. 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
- export type Resolve = (identity: string) => Promise<{ avatar?: Avatar; error?: string }>;
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
- export type Client = { client_id: string; client_name: string; redirect_uris: string[] };
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
- export type Grant = { identity: string; client_id: string; exp: number };
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
- return client;
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 = own(this.store.clients, q.get('client_id') ?? '');
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 = own(this.store.clients, p.client_id);
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 identity.
122
- async complete(id: string, identity: string): Promise<{ redirect: string } | { error: string }> {
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 revocation.
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 < now) delete this.store[k][t];
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`. An MCP server over stdio that reaches a
3
- // ward's owner asks through the daemon's root socket, and exposes four tools
4
- // and no more, plus the one read. Whoever runs this process is the owner, by
5
- // the device's own rules: the root of the ward here, or, with `via`, an
6
- // owner at another ward's door, on a standing the user being here holds
7
- // there. Every call is logged with what it made. The owner creates and
8
- // places; the work goes through `quo side`, under a gate.
9
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
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 { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
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
- export async function pilotSide(dir: string, ward: string, transport: Transport, log: Log = () => {}, via?: string): Promise<Serving> {
58
- const server = new Server({ name: 'quo-pilot', version: '0.0.0' }, { capabilities: { tools: {} } });
59
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: PILOT_TOOLS }));
60
- server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
61
- const name = req.params.name;
62
- const args: Record<string, unknown> = req.params.arguments ?? {};
63
- if (!PILOT_TOOLS.some((t) => t.name === name)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown ask' }) }], structuredContent: { error: 'unknown ask' }, isError: true };
64
- const out = await ask(dir, name === 'census' ? undefined : name, args, ward, via);
65
- const value = 'error' in out ? { error: out.error } : out.result;
66
- const failed = 'error' in out || (typeof value === 'object' && value !== null && 'error' in value);
67
- log(`${via === undefined ? ward : `${ward} via ${via}`} ${name} ${JSON.stringify(args)} -> ${JSON.stringify(value)}`);
68
- const structured = isRecord(value) ? { structuredContent: value } : {};
69
- return { content: [{ type: 'text', text: JSON.stringify(value) }], ...structured, ...(failed ? { isError: true } : {}) };
70
- });
71
- await server.connect(transport);
72
- return { close: () => server.close() };
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
- | resources, prompts | not mapped. Notes in the blueprint may carry hints. |
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 an MCP server over stdio that reaches a ward's owner
202
- asks, either as the root through the daemon's socket on the device, or as an
203
- owner the root invited, through a standing over the sealed door, and exposes
204
- four tools and no more:
205
-
206
- ```
207
- boot({ key, class, public? })
208
- invite({ being, id })
209
- knock({ being | { boot, key }, id, invitation, method?, args? })
210
- remove({ being, id })
211
- ```
212
-
213
- Plus one read, `census()`, which is the empty ask. Every call is logged
214
- with what it made. The pilot is handed to one local agent by the device's
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**: an MCP server over stdio exposing the four owner asks.
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 avatar, one transport. The mapping is
3
- // total and needs nothing beyond this envelope. tools/list is her describe,
4
- // tools/call is a named ask on her one standing, a push is a logging
5
- // notification, and the three words for "no object" each cross as the table
6
- // in quo-mcp.md says. The transport is whatever the caller connected: the
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 { Avatar } from '../beings/avatar.ts';
13
- import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
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
- // Her describe, spoken as tools. Name, description and input are verbatim;
19
- // an output schema crosses when she declared one.
20
- export function tools(bp: Blueprint): Tool[] {
21
- return bp.asks.map((a) => {
22
- const t: Tool = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
23
- if (a.description !== undefined) t.description = a.description;
24
- if (a.output !== undefined) t.outputSchema = { ...a.output, type: 'object' };
25
- return t;
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: Avatar, transport: Transport, after: () => Promise<void> = async () => {}): Promise<Serving> {
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
- return { tools: tools(bp as Blueprint) };
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 } : {};
@@ -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, for the page
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.user)), true;
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 === this.o.user || identity === 'desk') return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.user, 'An identity is one word, and not the user or the desk.')), true;
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
- const allowForm = (request: string, client: string, redirect: string, identity: string, user: string, err = '') => `<h1>Allow ${esc(client)}?</h1>
139
- <div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of <strong>${esc(user)}</strong>.</p>
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.0",
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.0",
71
+ "@quo-systems/quo": "^0.2.1",
68
72
  "esbuild": "^0.28.2",
69
73
  "ws": "^8.21.3"
70
74
  },