@quo-systems/dock 0.1.0
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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +27 -0
- package/beings/avatar.ts +84 -0
- package/beings/desk.ts +46 -0
- package/beings/index.ts +5 -0
- package/beings/quo-dock.md +698 -0
- package/beings/setup.ts +23 -0
- package/beings/side.ts +51 -0
- package/beings/user.ts +78 -0
- package/cli/client.ts +65 -0
- package/cli/daemon.ts +582 -0
- package/cli/quo.ts +126 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +82 -0
- package/dist/beings/desk.d.ts +42 -0
- package/dist/beings/desk.js +43 -0
- package/dist/beings/index.d.ts +3 -0
- package/dist/beings/index.js +5 -0
- package/dist/beings/setup.d.ts +2 -0
- package/dist/beings/setup.js +16 -0
- package/dist/beings/side.d.ts +25 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +108 -0
- package/dist/beings/user.js +74 -0
- package/dist/cli/client.d.ts +14 -0
- package/dist/cli/client.js +51 -0
- package/dist/cli/daemon.d.ts +57 -0
- package/dist/cli/daemon.js +581 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +121 -0
- package/dist/harbor/browser.d.ts +13 -0
- package/dist/harbor/browser.js +50 -0
- package/dist/harbor/disk.d.ts +19 -0
- package/dist/harbor/disk.js +126 -0
- package/dist/harbor/edge/edge.d.ts +26 -0
- package/dist/harbor/edge/edge.js +123 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +179 -0
- package/dist/harbor/edge/storage.d.ts +20 -0
- package/dist/harbor/edge/storage.js +81 -0
- package/dist/harbor/edge/worker.d.ts +14 -0
- package/dist/harbor/edge/worker.js +47 -0
- package/dist/harbor/files.d.ts +14 -0
- package/dist/harbor/files.js +83 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +85 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/html.d.ts +32 -0
- package/dist/human/html.js +136 -0
- package/dist/human/screen.d.ts +11 -0
- package/dist/human/screen.js +66 -0
- package/dist/human/tab.d.ts +7 -0
- package/dist/human/tab.js +68 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +96 -0
- package/dist/mcp/oauth.d.ts +98 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/pilot.d.ts +6 -0
- package/dist/mcp/pilot.js +68 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +94 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +63 -0
- package/dist/mcp/web/exchange.d.ts +23 -0
- package/dist/mcp/web/exchange.js +136 -0
- package/harbor/browser.ts +63 -0
- package/harbor/disk.ts +120 -0
- package/harbor/edge/edge.ts +147 -0
- package/harbor/edge/exercise.ts +187 -0
- package/harbor/edge/platform.d.ts +28 -0
- package/harbor/edge/storage.ts +82 -0
- package/harbor/edge/worker.ts +47 -0
- package/harbor/edge/wrangler.toml +21 -0
- package/harbor/files.ts +89 -0
- package/harbor/idb.ts +90 -0
- package/harbor/quo-harbor.md +403 -0
- package/human/dom.ts +21 -0
- package/human/html.ts +164 -0
- package/human/quo-human.md +86 -0
- package/human/screen.ts +76 -0
- package/human/tab.ts +84 -0
- package/mcp/agent.ts +94 -0
- package/mcp/http.ts +96 -0
- package/mcp/oauth.ts +260 -0
- package/mcp/pilot.ts +73 -0
- package/mcp/quo-mcp.md +262 -0
- package/mcp/runner.ts +131 -0
- package/mcp/server.ts +67 -0
- package/mcp/web/exchange.ts +146 -0
- package/package.json +89 -0
package/mcp/oauth.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// OAuth on the mcp route: the credential exchange for a remote MCP client.
|
|
3
|
+
// An authorization server small enough to read, and nothing a client does
|
|
4
|
+
// not need: metadata, dynamic registration, PKCE with S256, the code grant,
|
|
5
|
+
// refresh, opaque tokens. It is a route, not Quo. Its one job is to end with
|
|
6
|
+
// a client identity, which the front desk trades for an invitation and the
|
|
7
|
+
// avatar knocks with; the tokens it keeps map a bearer to that identity and
|
|
8
|
+
// nothing else. No secret of Quo's is ever here.
|
|
9
|
+
//
|
|
10
|
+
// GET /.well-known/oauth-authorization-server who issues, and where
|
|
11
|
+
// GET /.well-known/oauth-protected-resource[/mcp] which issuer guards the MCP endpoint
|
|
12
|
+
// POST /register a client names itself and its redirects
|
|
13
|
+
// GET /authorize starts a request; sent to the web route to finish
|
|
14
|
+
// POST /token code or refresh for an access token
|
|
15
|
+
//
|
|
16
|
+
// The web route finishes a request by calling `complete(request, identity)`
|
|
17
|
+
// after its own login and allow pages; that returns the redirect that
|
|
18
|
+
// carries the code back to the client. Step 8 builds those pages.
|
|
19
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
20
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
21
|
+
|
|
22
|
+
// A token, a code, a client id: every key here is a string the far side chose,
|
|
23
|
+
// and a bare lookup would find `__proto__` and hand back an object with no
|
|
24
|
+
// identity. Own keys only.
|
|
25
|
+
const own = <T>(rec: Record<string, T>, key: string): T | undefined => (Object.hasOwn(rec, key) ? rec[key] : undefined);
|
|
26
|
+
|
|
27
|
+
export const CODE_TTL = 10 * 60 * 1000; // a code, and a pending request, live ten minutes
|
|
28
|
+
export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
|
|
29
|
+
export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
|
|
30
|
+
|
|
31
|
+
export type Client = { client_id: string; client_name: string; redirect_uris: string[] };
|
|
32
|
+
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 };
|
|
34
|
+
export type Store = {
|
|
35
|
+
clients: Record<string, Client>;
|
|
36
|
+
pending: Record<string, Pending>;
|
|
37
|
+
codes: Record<string, Pending & { identity: string }>;
|
|
38
|
+
access: Record<string, Grant>;
|
|
39
|
+
refresh: Record<string, Grant>;
|
|
40
|
+
};
|
|
41
|
+
export const emptyStore = (): Store => ({ clients: {}, pending: {}, codes: {}, access: {}, refresh: {} });
|
|
42
|
+
|
|
43
|
+
export type Options = {
|
|
44
|
+
issuer: string; // https://mcp.lab.quo.systems
|
|
45
|
+
resource: string; // https://mcp.lab.quo.systems/mcp
|
|
46
|
+
finish: (request: string) => string; // where the web route takes a pending request
|
|
47
|
+
store: Store;
|
|
48
|
+
persist: (store: Store) => Promise<void>;
|
|
49
|
+
now?: () => number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const token = () => randomBytes(32).toString('base64url');
|
|
53
|
+
const s256 = (v: string) => createHash('sha256').update(v).digest('base64url');
|
|
54
|
+
|
|
55
|
+
export class OAuth {
|
|
56
|
+
readonly o: Options;
|
|
57
|
+
constructor(o: Options) {
|
|
58
|
+
this.o = o;
|
|
59
|
+
}
|
|
60
|
+
get store() {
|
|
61
|
+
return this.o.store;
|
|
62
|
+
}
|
|
63
|
+
now() {
|
|
64
|
+
return this.o.now?.() ?? Date.now();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
metadata() {
|
|
68
|
+
const i = this.o.issuer;
|
|
69
|
+
return {
|
|
70
|
+
issuer: i,
|
|
71
|
+
authorization_endpoint: `${i}/authorize`,
|
|
72
|
+
token_endpoint: `${i}/token`,
|
|
73
|
+
registration_endpoint: `${i}/register`,
|
|
74
|
+
response_types_supported: ['code'],
|
|
75
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
76
|
+
code_challenge_methods_supported: ['S256'],
|
|
77
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
78
|
+
scopes_supported: ['quo'],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protectedResource() {
|
|
83
|
+
return { resource: this.o.resource, authorization_servers: [this.o.issuer], bearer_methods_supported: ['header'] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async register(body: unknown): Promise<Client | { error: string }> {
|
|
87
|
+
const b = body as { client_name?: unknown; redirect_uris?: unknown };
|
|
88
|
+
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
|
|
89
|
+
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 };
|
|
91
|
+
this.store.clients[client.client_id] = client;
|
|
92
|
+
await this.o.persist(this.store);
|
|
93
|
+
return client;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Start a request. What comes back is where to send the browser: the web
|
|
97
|
+
// route with the request id, or the client's redirect with an error.
|
|
98
|
+
async authorize(q: URLSearchParams): Promise<{ redirect: string } | { error: string }> {
|
|
99
|
+
const client = own(this.store.clients, q.get('client_id') ?? '');
|
|
100
|
+
const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
|
|
101
|
+
if (!client || redirect === null || !client.redirect_uris.includes(redirect)) return { error: 'invalid_client' };
|
|
102
|
+
const back = (error: string) => ({ redirect: withQuery(redirect, { error, state: q.get('state') }) });
|
|
103
|
+
if (q.get('response_type') !== 'code') return back('unsupported_response_type');
|
|
104
|
+
const challenge = q.get('code_challenge');
|
|
105
|
+
if (!challenge || q.get('code_challenge_method') !== 'S256') return back('invalid_request');
|
|
106
|
+
const id = token();
|
|
107
|
+
this.store.pending[id] = { client_id: client.client_id, redirect_uri: redirect, challenge, state: q.get('state'), resource: q.get('resource'), exp: this.now() + CODE_TTL };
|
|
108
|
+
this.sweep();
|
|
109
|
+
await this.o.persist(this.store);
|
|
110
|
+
return { redirect: this.o.finish(id) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// What the web route shows: who is asking. Null if the request is gone.
|
|
114
|
+
pending(id: string): (Pending & { client: Client }) | null {
|
|
115
|
+
const p = own(this.store.pending, id);
|
|
116
|
+
if (!p || p.exp < this.now()) return null;
|
|
117
|
+
const client = own(this.store.clients, p.client_id);
|
|
118
|
+
return client ? { ...p, client } : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
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 }> {
|
|
123
|
+
const p = this.pending(id);
|
|
124
|
+
if (!p) return { error: 'expired' };
|
|
125
|
+
delete this.store.pending[id];
|
|
126
|
+
const code = token();
|
|
127
|
+
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
128
|
+
await this.o.persist(this.store);
|
|
129
|
+
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The human refused, or the request died: the client hears that.
|
|
133
|
+
async deny(id: string): Promise<{ redirect: string } | { error: string }> {
|
|
134
|
+
const p = this.pending(id);
|
|
135
|
+
if (!p) return { error: 'expired' };
|
|
136
|
+
delete this.store.pending[id];
|
|
137
|
+
await this.o.persist(this.store);
|
|
138
|
+
return { redirect: withQuery(p.redirect_uri, { error: 'access_denied', state: p.state }) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async token(body: URLSearchParams): Promise<Record<string, unknown>> {
|
|
142
|
+
const grant = body.get('grant_type');
|
|
143
|
+
if (grant === 'authorization_code') {
|
|
144
|
+
const c = own(this.store.codes, body.get('code') ?? '');
|
|
145
|
+
if (!c || c.exp < this.now()) return { error: 'invalid_grant' };
|
|
146
|
+
delete this.store.codes[body.get('code') ?? '']; // a code is spent the first time it is offered, right or wrong
|
|
147
|
+
const verifier = body.get('code_verifier') ?? '';
|
|
148
|
+
if (s256(verifier) !== c.challenge || (body.get('client_id') ?? c.client_id) !== c.client_id || (body.get('redirect_uri') ?? c.redirect_uri) !== c.redirect_uri) {
|
|
149
|
+
await this.o.persist(this.store);
|
|
150
|
+
return { error: 'invalid_grant' };
|
|
151
|
+
}
|
|
152
|
+
return this.issue(c.identity, c.client_id);
|
|
153
|
+
}
|
|
154
|
+
if (grant === 'refresh_token') {
|
|
155
|
+
const rt = body.get('refresh_token') ?? '';
|
|
156
|
+
const r = own(this.store.refresh, rt);
|
|
157
|
+
if (!r || r.exp < this.now()) return { error: 'invalid_grant' };
|
|
158
|
+
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);
|
|
160
|
+
}
|
|
161
|
+
return { error: 'unsupported_grant_type' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async issue(identity: string, client_id: string) {
|
|
165
|
+
const access_token = token(),
|
|
166
|
+
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 };
|
|
169
|
+
this.sweep();
|
|
170
|
+
await this.o.persist(this.store);
|
|
171
|
+
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
175
|
+
bearer(req: IncomingMessage): string | null {
|
|
176
|
+
const h = req.headers.authorization ?? '';
|
|
177
|
+
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
178
|
+
const g = own(this.store.access, t);
|
|
179
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
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];
|
|
186
|
+
await this.o.persist(this.store);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
sweep() {
|
|
190
|
+
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];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The HTTP face. `rest` is the path under the route.
|
|
195
|
+
async handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean> {
|
|
196
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
197
|
+
const json = (status: number, body: unknown, headers: Record<string, string> = {}) => {
|
|
198
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });
|
|
199
|
+
res.end(JSON.stringify(body));
|
|
200
|
+
};
|
|
201
|
+
if (rest === '/.well-known/oauth-authorization-server' && req.method === 'GET') return json(200, this.metadata()), true;
|
|
202
|
+
if ((rest === '/.well-known/oauth-protected-resource' || rest === '/.well-known/oauth-protected-resource/mcp') && req.method === 'GET') return json(200, this.protectedResource()), true;
|
|
203
|
+
if (rest === '/register' && req.method === 'POST') {
|
|
204
|
+
const out = await this.register(await readJson(req));
|
|
205
|
+
return json('error' in out ? 400 : 201, out), true;
|
|
206
|
+
}
|
|
207
|
+
if (rest === '/authorize' && req.method === 'GET') {
|
|
208
|
+
const out = await this.authorize(url.searchParams);
|
|
209
|
+
if ('error' in out) return json(400, out), true;
|
|
210
|
+
res.writeHead(302, { location: out.redirect, 'cache-control': 'no-store' });
|
|
211
|
+
res.end();
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (rest === '/token' && req.method === 'POST') {
|
|
215
|
+
const out = await this.token(await readForm(req));
|
|
216
|
+
return json('error' in out ? 400 : 200, out), true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// What an unauthenticated MCP request is told: where to go.
|
|
222
|
+
challenge(res: ServerResponse) {
|
|
223
|
+
res.writeHead(401, { 'www-authenticate': `Bearer resource_metadata="${this.o.issuer}/.well-known/oauth-protected-resource/mcp"`, 'content-type': 'application/json' });
|
|
224
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function withQuery(base: string, params: Record<string, string | null>): string {
|
|
229
|
+
const u = new URL(base);
|
|
230
|
+
for (const [k, v] of Object.entries(params)) if (v !== null) u.searchParams.set(k, v);
|
|
231
|
+
return u.toString();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function readBody(req: IncomingMessage, limit = 64 * 1024): Promise<string> {
|
|
235
|
+
let s = '';
|
|
236
|
+
for await (const chunk of req) {
|
|
237
|
+
s += chunk;
|
|
238
|
+
if (s.length > limit) throw new Error('too large');
|
|
239
|
+
}
|
|
240
|
+
return s;
|
|
241
|
+
}
|
|
242
|
+
async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(await readBody(req));
|
|
245
|
+
} catch {
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export async function readForm(req: IncomingMessage): Promise<URLSearchParams> {
|
|
250
|
+
const body = await readBody(req);
|
|
251
|
+
if ((req.headers['content-type'] ?? '').includes('application/json')) {
|
|
252
|
+
try {
|
|
253
|
+
const o = JSON.parse(body) as Record<string, unknown>;
|
|
254
|
+
return new URLSearchParams(Object.entries(o).map(([k, v]) => [k, String(v)]));
|
|
255
|
+
} catch {
|
|
256
|
+
return new URLSearchParams();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return new URLSearchParams(body);
|
|
260
|
+
}
|
package/mcp/pilot.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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';
|
|
10
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
11
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
import { ask } from '../cli/client.ts';
|
|
13
|
+
import type { Serving } from '../beings/side.ts';
|
|
14
|
+
|
|
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
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
56
|
+
|
|
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() };
|
|
73
|
+
}
|
package/mcp/quo-mcp.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# The model side
|
|
2
|
+
|
|
3
|
+
This is the one way a model meets a Quo world. It is one side of the avatar
|
|
4
|
+
described in `packages/dock/beings/quo-dock.md`, which owns the avatar, the front
|
|
5
|
+
desk, the credential exchange, the roles and the shared invariants; this
|
|
6
|
+
document assumes all of that and adds only what a model needs. It is
|
|
7
|
+
model-agnostic on purpose. Claude, GPT, Kimi, a model in LM Studio, a model
|
|
8
|
+
behind OpenRouter: capable or not, local or hosted, speaking MCP or plain
|
|
9
|
+
function calling, every one meets a world in exactly one shape.
|
|
10
|
+
|
|
11
|
+
A remote model, claude.ai or an API behind a vendor, is an occupant and
|
|
12
|
+
nothing else. A local agent, Claude Code, `claude -p`, or any CLI agent on
|
|
13
|
+
the Mac or the droplet, may also be the owner and the developer, and keeps
|
|
14
|
+
the three apart.
|
|
15
|
+
|
|
16
|
+
## What MCP is, in Quo words
|
|
17
|
+
|
|
18
|
+
MCP is the description and value layer and nothing more. The mapping is total
|
|
19
|
+
and needs no translation code beyond an envelope:
|
|
20
|
+
|
|
21
|
+
| MCP | Quo |
|
|
22
|
+
| ------------------------ | ------------------------------------------------------------ |
|
|
23
|
+
| server | an avatar's side |
|
|
24
|
+
| session | a standing the avatar holds, persistent across sessions |
|
|
25
|
+
| tools/list | the empty ask on that standing: describe for this asker |
|
|
26
|
+
| tool name, inputSchema | `asks[].name`, `asks[].input`, verbatim |
|
|
27
|
+
| tools/call | a named ask with args |
|
|
28
|
+
| result content | the answer object, as JSON |
|
|
29
|
+
| tool error result | an error object she answered, `{ error }`, as JSON |
|
|
30
|
+
| protocol error | silence and the ward's words, see the table below |
|
|
31
|
+
| auth token | an invitation, used once, then keys |
|
|
32
|
+
| resources, prompts | not mapped. Notes in the blueprint may carry hints. |
|
|
33
|
+
|
|
34
|
+
A tool list is a describe. Because a being describes per asker, two models
|
|
35
|
+
connected to the same user being see two different tool lists, and neither
|
|
36
|
+
can call what it cannot see: the gate is one decision for describe and for
|
|
37
|
+
dispatch.
|
|
38
|
+
|
|
39
|
+
The three words for "no object" cross the envelope like this:
|
|
40
|
+
|
|
41
|
+
| the avatar heard | the client gets |
|
|
42
|
+
| ---------------- | ------------------------------------------------------------------- |
|
|
43
|
+
| an object | a tool result with that JSON |
|
|
44
|
+
| `{ error }` | a tool result marked as error, with that JSON |
|
|
45
|
+
| silence | an error result, `{ error: 'silence' }`: the work may have happened |
|
|
46
|
+
| unreached | an error result, `{ error: 'unreached' }`: nothing was delivered |
|
|
47
|
+
| another word | an error result, `{ error: word }`, with what the side says of it |
|
|
48
|
+
| `removed` | at session open: 401, the token forgotten, the exchange again |
|
|
49
|
+
|
|
50
|
+
Silence is never turned into a retry by the envelope. The model decides, and
|
|
51
|
+
the two words give it what it needs to decide.
|
|
52
|
+
|
|
53
|
+
## Flows
|
|
54
|
+
|
|
55
|
+
### A remote MCP client connects
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
client mcp. route front desk user being avatar
|
|
59
|
+
|-- OAuth ------->| | | |
|
|
60
|
+
| |-- device(proof) --->| | |
|
|
61
|
+
| | |-- invite ------->| |
|
|
62
|
+
| | |<-- invitation ---| |
|
|
63
|
+
| |<-- invitation ------| | |
|
|
64
|
+
| |-- boot or find avatar for (human, client) ------------>|
|
|
65
|
+
| |-- knock(invitation) --------------------------------->|
|
|
66
|
+
| | | |<-- knock ------|
|
|
67
|
+
| | | |--- answer ---->|
|
|
68
|
+
| | take |
|
|
69
|
+
|<-- initialize --| |
|
|
70
|
+
|-- tools/list -->|-- ask() ------------------------------------------------>|--> user being
|
|
71
|
+
|<-- tool list ---|<-- describe for this asker --------------------------------|
|
|
72
|
+
|-- tools/call -->|-- ask(name, args, { time }) ---------------------------->|--> user being
|
|
73
|
+
|<-- result ------|<-- object | error | silence | unreached ------------------|
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### A local client connects over stdio
|
|
77
|
+
|
|
78
|
+
Identical, with the differences that there is no OAuth, the proof is that the
|
|
79
|
+
process runs as the human on the device, and the avatar lives in the laptop
|
|
80
|
+
harbor. No droplet is needed for this. A dialer harbor is enough, because the
|
|
81
|
+
model is on the same machine as the ward it talks to.
|
|
82
|
+
|
|
83
|
+
### Two ways a model is driven
|
|
84
|
+
|
|
85
|
+
Every model is driven in one of two ways, and the avatar is the same in
|
|
86
|
+
both. What differs is who starts the loop.
|
|
87
|
+
|
|
88
|
+
**The human is the loop.** You are in a chat and the model is your hands.
|
|
89
|
+
You say "book the visit", it chains availability, calendar and book, each
|
|
90
|
+
one an ask through your gate under the id you minted for it, and acme sees
|
|
91
|
+
your id. Without the model you would press the buttons yourself, and that
|
|
92
|
+
sentence is literally true: the screen is the user being's describe spoken
|
|
93
|
+
as forms, the tool list is the same describe spoken as tools. Two sides onto
|
|
94
|
+
one gate, and neither can do a thing the other cannot.
|
|
95
|
+
|
|
96
|
+
**The world is the loop.** An agent lives alone in a folder with its own
|
|
97
|
+
constitution, a `CLAUDE.md` or the equivalent for any other model, and is
|
|
98
|
+
woken by events. The flow:
|
|
99
|
+
|
|
100
|
+
1. **The trigger is a reverse ask.** The user being holds a standing to the
|
|
101
|
+
agent's avatar and asks it when something happens. The avatar's `answer`
|
|
102
|
+
is the side: it starts a run, `claude -p` or any CLI agent, in the
|
|
103
|
+
agent's folder with the event as the prompt.
|
|
104
|
+
2. **Ack now, report later.** A run takes minutes; an ask waits thirty
|
|
105
|
+
seconds by default and five at the ceiling, and a late reply is not read.
|
|
106
|
+
So the avatar answers at once with an acknowledgement, and when the run
|
|
107
|
+
ends it asks the user being back on its own standing with the result.
|
|
108
|
+
A run never holds an ask open.
|
|
109
|
+
3. **One run at a time.** Asks are concurrent by design, so two events would
|
|
110
|
+
start two runs in one folder. The avatar serializes runs per agent: one
|
|
111
|
+
constitution, one train of thought. A queue in her cells is enough.
|
|
112
|
+
4. **Roles stay apart in one agent.** The run that does chores goes through
|
|
113
|
+
the gate as an occupant. If the same agent also boots beings, it holds the
|
|
114
|
+
owner pilot, and that is the estate: a separate run, through the pilot,
|
|
115
|
+
never the one doing the work.
|
|
116
|
+
|
|
117
|
+
Two layers of permission exist here, and only one is real. The gate on the
|
|
118
|
+
user being decides what the agent may ask, in hard terms. The constitution
|
|
119
|
+
decides how it behaves, in soft terms. A permission never lives in the soft
|
|
120
|
+
layer: a run gone wrong can do exactly what the gate shows the asker
|
|
121
|
+
"agent", so that list is the narrowest that still gets the job done.
|
|
122
|
+
|
|
123
|
+
The conventions this side pins, in `packages/dock/mcp/agent.ts`:
|
|
124
|
+
|
|
125
|
+
| the agent side does | as |
|
|
126
|
+
| --------------------- | --------------------------------------------------------------- |
|
|
127
|
+
| the event | the object the user being pushed; `notify` answers at once |
|
|
128
|
+
| the queue | her inbox, in her cells, so it survives the side and a reboot |
|
|
129
|
+
| a run | a function of the event, or a process in the agent's folder |
|
|
130
|
+
| the process | the command, then the event as JSON as its last argument |
|
|
131
|
+
| its result | `{ output }` from stdout, or `{ error, stderr }` on a bad exit |
|
|
132
|
+
| a run that throws | `{ error }` with what it said |
|
|
133
|
+
| the callback | `report({ event, result })` on the user being, gated to devices |
|
|
134
|
+
| a report unanswered | dropped, never retried, never held |
|
|
135
|
+
| two events | one after the other, first in first out |
|
|
136
|
+
| on a device | `<dir>/agents.json`: identity to command, args and folder |
|
|
137
|
+
| who wakes her | the desk, or a device the human allowed to wake at the exchange |
|
|
138
|
+
|
|
139
|
+
An event leaves the queue before its run starts, so a crash mid-run loses that
|
|
140
|
+
event and never runs it twice. A run costs the human's quota, so the agent side
|
|
141
|
+
never starts one on its own: only a push starts a run, one per event, never
|
|
142
|
+
retried, and the command in `agents.json` names the model and the tools it may
|
|
143
|
+
have; on the lab that is Haiku and no tools. `report` is the one ask this step
|
|
144
|
+
added to the user being: every device may say what a run of its found, and she
|
|
145
|
+
keeps it in her cells for whoever renders her. The proof is
|
|
146
|
+
`packages/dock/test/agent.test.ts` with a fake run and a fake process.
|
|
147
|
+
|
|
148
|
+
### A model that does not speak MCP
|
|
149
|
+
|
|
150
|
+
OpenAI-style function calling, Kimi, LM Studio, OpenRouter, anything with a
|
|
151
|
+
tools array. The side is a **runner**: a loop you own that holds the avatar's
|
|
152
|
+
standing on one side and the model's API on the other.
|
|
153
|
+
|
|
154
|
+
- The runner calls the empty ask and hands the model `asks[]` as its tool
|
|
155
|
+
definitions. The schema is already JSON Schema 2020-12; most APIs take it
|
|
156
|
+
verbatim, and the runner narrows it where one does not.
|
|
157
|
+
- Each tool call from the model becomes a named ask on the avatar's standing.
|
|
158
|
+
- Each answer, error, silence or word goes back as the tool result, in the
|
|
159
|
+
same mapping as above.
|
|
160
|
+
|
|
161
|
+
The runner is not a second architecture. It is an MCP client written inline,
|
|
162
|
+
and it uses the same avatar, the same invitation, the same gate. A model's
|
|
163
|
+
capability changes how well it uses the tools and nothing about what it can
|
|
164
|
+
reach: a weak model calls the wrong ask and gets an error object.
|
|
165
|
+
|
|
166
|
+
What moved in-process is the host. An MCP client drives the loop from
|
|
167
|
+
outside; a completions API cannot, so the runner drives it. It holds one
|
|
168
|
+
conversation for as long as it serves, and two things start a turn in it:
|
|
169
|
+
a line from the human, which is the human being the loop, and a push from
|
|
170
|
+
the user being, which is the world being the loop. The conventions the
|
|
171
|
+
runner pins, in `packages/dock/mcp/runner.ts`:
|
|
172
|
+
|
|
173
|
+
| the runner does | as |
|
|
174
|
+
| ------------------------------ | ------------------------------------------------------------ |
|
|
175
|
+
| the model | a base URL, a model name, a bearer from the device if wanted |
|
|
176
|
+
| her describe | `tools[]` of `type: function`, name, description, parameters |
|
|
177
|
+
| an ask with no properties | sent with `properties: {}`; LM Studio refuses it bare |
|
|
178
|
+
| a tool call | a named ask; arguments that are not JSON are an error object |
|
|
179
|
+
| a tool result | one `tool` message: JSON, or the text of the two words |
|
|
180
|
+
| a push | a `user` message holding the object; it starts a turn |
|
|
181
|
+
| a turn | until the model answers with no tool call, or ten calls |
|
|
182
|
+
| two turns | never at once: the second waits, one train of thought |
|
|
183
|
+
|
|
184
|
+
Ten is a ceiling, not a permission: a turn that ends there has no final
|
|
185
|
+
text, and the gate decided everything the model reached. The proof is
|
|
186
|
+
`packages/dock/test/runner.test.ts` over a scripted endpoint on loopback, and the
|
|
187
|
+
same suite runs one live turn when `QUO_MODEL_URL` names a real endpoint.
|
|
188
|
+
|
|
189
|
+
From a terminal the runner is `quo run --as NAME --url URL --model NAME`:
|
|
190
|
+
the same pipe as `quo side`, with the model named in the hello line, so
|
|
191
|
+
the daemon runs the runner beside the avatar it boots for the name. A line
|
|
192
|
+
typed is a turn; the model's final text is a line back; a push from the
|
|
193
|
+
user being starts a turn of its own and its text comes back the same way.
|
|
194
|
+
A turn that ends at the ceiling, `--turns`, has no final text and comes
|
|
195
|
+
back as an empty line; a model that fails comes back as one line holding
|
|
196
|
+
an error object. The bearer, `QUO_MODEL_KEY`, is read by the command from
|
|
197
|
+
the device and crosses only the local socket.
|
|
198
|
+
|
|
199
|
+
### A model as the owner
|
|
200
|
+
|
|
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
|
|
218
|
+
far ward, taken when the root here knocked for her with an invitation the
|
|
219
|
+
far root minted on its ward's pk, and every tool call is a sealed ask
|
|
220
|
+
there, answered as the far ward answers an owner at its door.
|
|
221
|
+
|
|
222
|
+
The owner does not do the work. A Claude Code session that boots a being and
|
|
223
|
+
wants to try it connects to an avatar like anyone else and asks through the
|
|
224
|
+
gate. If it could act as the owner, the arrival would be unnamed and no gate
|
|
225
|
+
would have judged it.
|
|
226
|
+
|
|
227
|
+
### The estate agent
|
|
228
|
+
|
|
229
|
+
The spec has no estate on purpose. On the droplet it is `claude -p`, or any
|
|
230
|
+
CLI agent, under cron, holding the owner pilot and one avatar:
|
|
231
|
+
|
|
232
|
+
- through the pilot: read the census, boot again a being whose digest is
|
|
233
|
+
null this run, report a class the harbor no longer holds.
|
|
234
|
+
- through the avatar: run the chores the human gated to it.
|
|
235
|
+
|
|
236
|
+
Nothing about the estate agent is inside a ward. Lose the cron and the world
|
|
237
|
+
keeps running; it just stops being looked after.
|
|
238
|
+
|
|
239
|
+
### A model as the developer
|
|
240
|
+
|
|
241
|
+
A local agent writes a being class to disk. The harbor holds class bodies, so
|
|
242
|
+
a new class is a file the harbor loads and a name the owner boots. The
|
|
243
|
+
developer role is a file system and an editor, plus the owner pilot to boot.
|
|
244
|
+
Nothing crosses the wire.
|
|
245
|
+
|
|
246
|
+
## Invariants of this side
|
|
247
|
+
|
|
248
|
+
On top of the shared ones in the trunk:
|
|
249
|
+
|
|
250
|
+
1. A run never holds an ask open: it acknowledges, and asks back with the
|
|
251
|
+
result.
|
|
252
|
+
2. A permission lives in a gate, never in a constitution.
|
|
253
|
+
3. The runner and the MCP server are two envelopes over one avatar; no
|
|
254
|
+
behaviour lives in either that the other lacks.
|
|
255
|
+
|
|
256
|
+
## Glossary of this side
|
|
257
|
+
|
|
258
|
+
- **constitution**: the folder and instructions an autonomous agent runs
|
|
259
|
+
under; governance, never permission.
|
|
260
|
+
- **runner**: an inline MCP client for a model that speaks function calling;
|
|
261
|
+
it drives the loop the MCP client would, over one conversation.
|
|
262
|
+
- **owner pilot**: an MCP server over stdio exposing the four owner asks.
|