@nervur-org/dock 0.3.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/GETTING_STARTED.md +128 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +37 -0
- package/api/quo-api.md +196 -0
- package/api/route.ts +227 -0
- package/beings/GLOSSARY.md +118 -0
- package/beings/WORLDS.md +567 -0
- package/beings/avatar.ts +101 -0
- package/beings/carry.ts +159 -0
- package/beings/clock.ts +552 -0
- package/beings/courier.ts +107 -0
- package/beings/desk.ts +128 -0
- package/beings/doorbell.ts +129 -0
- package/beings/envoy.ts +218 -0
- package/beings/index.ts +16 -0
- package/beings/join.ts +51 -0
- package/beings/link.ts +104 -0
- package/beings/look.ts +139 -0
- package/beings/porter.ts +43 -0
- package/beings/presence.ts +39 -0
- package/beings/quo-app-bells.md +280 -0
- package/beings/quo-dock.md +1942 -0
- package/beings/setup.ts +45 -0
- package/beings/side.ts +60 -0
- package/beings/user.ts +459 -0
- package/beings/way.ts +65 -0
- package/cli/bundles.ts +20 -0
- package/cli/client.ts +73 -0
- package/cli/daemon.ts +581 -0
- package/cli/estate/Caddyfile +33 -0
- package/cli/estate/quo.service +35 -0
- package/cli/estate/systems.quo.plist +44 -0
- package/cli/estate.ts +48 -0
- package/cli/folder.ts +27 -0
- package/cli/oauth.ts +32 -0
- package/cli/pilot.ts +38 -0
- package/cli/quo-dock.md +1942 -0
- package/cli/quo.ts +203 -0
- package/cli/stand.ts +160 -0
- package/dist/api/route.d.ts +21 -0
- package/dist/api/route.js +193 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +100 -0
- package/dist/beings/carry.d.ts +12 -0
- package/dist/beings/carry.js +174 -0
- package/dist/beings/clock.d.ts +161 -0
- package/dist/beings/clock.js +528 -0
- package/dist/beings/courier.d.ts +50 -0
- package/dist/beings/courier.js +96 -0
- package/dist/beings/desk.d.ts +53 -0
- package/dist/beings/desk.js +135 -0
- package/dist/beings/doorbell.d.ts +78 -0
- package/dist/beings/doorbell.js +133 -0
- package/dist/beings/envoy.d.ts +54 -0
- package/dist/beings/envoy.js +211 -0
- package/dist/beings/index.d.ts +14 -0
- package/dist/beings/index.js +16 -0
- package/dist/beings/join.d.ts +15 -0
- package/dist/beings/join.js +42 -0
- package/dist/beings/link.d.ts +14 -0
- package/dist/beings/link.js +91 -0
- package/dist/beings/look.d.ts +30 -0
- package/dist/beings/look.js +104 -0
- package/dist/beings/porter.d.ts +14 -0
- package/dist/beings/porter.js +41 -0
- package/dist/beings/presence.d.ts +8 -0
- package/dist/beings/presence.js +21 -0
- package/dist/beings/setup.d.ts +3 -0
- package/dist/beings/setup.js +39 -0
- package/dist/beings/side.d.ts +32 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +315 -0
- package/dist/beings/user.js +462 -0
- package/dist/beings/way.d.ts +19 -0
- package/dist/beings/way.js +28 -0
- package/dist/cli/bundles.d.ts +5 -0
- package/dist/cli/bundles.js +17 -0
- package/dist/cli/client.d.ts +15 -0
- package/dist/cli/client.js +58 -0
- package/dist/cli/daemon.d.ts +50 -0
- package/dist/cli/daemon.js +584 -0
- package/dist/cli/estate/Caddyfile +33 -0
- package/dist/cli/estate/quo.service +35 -0
- package/dist/cli/estate/systems.quo.plist +44 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +50 -0
- package/dist/cli/folder.d.ts +2 -0
- package/dist/cli/folder.js +25 -0
- package/dist/cli/oauth.d.ts +2 -0
- package/dist/cli/oauth.js +32 -0
- package/dist/cli/pilot.d.ts +5 -0
- package/dist/cli/pilot.js +22 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +201 -0
- package/dist/cli/stand.d.ts +37 -0
- package/dist/cli/stand.js +110 -0
- package/dist/dock/agents.d.ts +102 -0
- package/dist/dock/agents.js +105 -0
- package/dist/dock/apns.d.ts +27 -0
- package/dist/dock/apns.js +98 -0
- package/dist/dock/console.d.ts +6 -0
- package/dist/dock/console.js +20 -0
- package/dist/dock/dialers.d.ts +56 -0
- package/dist/dock/dialers.js +75 -0
- package/dist/dock/faculty.d.ts +24 -0
- package/dist/dock/faculty.js +142 -0
- package/dist/dock/fcm.d.ts +13 -0
- package/dist/dock/fcm.js +77 -0
- package/dist/dock/grants.d.ts +41 -0
- package/dist/dock/grants.js +99 -0
- package/dist/dock/http2.d.ts +4 -0
- package/dist/dock/http2.js +68 -0
- package/dist/dock/index.d.ts +41 -0
- package/dist/dock/index.js +113 -0
- package/dist/dock/local.d.ts +13 -0
- package/dist/dock/local.js +44 -0
- package/dist/dock/location.d.ts +19 -0
- package/dist/dock/location.js +11 -0
- package/dist/dock/notifications.d.ts +38 -0
- package/dist/dock/notifications.js +60 -0
- package/dist/dock/push.d.ts +79 -0
- package/dist/dock/push.js +132 -0
- package/dist/dock/service.d.ts +4 -0
- package/dist/dock/service.js +107 -0
- package/dist/dock/timer.d.ts +52 -0
- package/dist/dock/timer.js +149 -0
- package/dist/dock/webpush.d.ts +12 -0
- package/dist/dock/webpush.js +144 -0
- package/dist/harbor/browser.d.ts +15 -0
- package/dist/harbor/browser.js +82 -0
- package/dist/harbor/capacitor.d.ts +28 -0
- package/dist/harbor/capacitor.js +218 -0
- package/dist/harbor/disk.d.ts +27 -0
- package/dist/harbor/disk.js +249 -0
- package/dist/harbor/edge/alarm.d.ts +10 -0
- package/dist/harbor/edge/alarm.js +38 -0
- package/dist/harbor/edge/edge.d.ts +54 -0
- package/dist/harbor/edge/edge.js +370 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +232 -0
- package/dist/harbor/edge/given.d.ts +26 -0
- package/dist/harbor/edge/given.js +42 -0
- package/dist/harbor/edge/platform.d.ts +39 -0
- package/dist/harbor/edge/storage.d.ts +30 -0
- package/dist/harbor/edge/storage.js +180 -0
- package/dist/harbor/edge/worker.d.ts +17 -0
- package/dist/harbor/edge/worker.js +67 -0
- package/dist/harbor/files.d.ts +15 -0
- package/dist/harbor/files.js +220 -0
- package/dist/harbor/http.d.ts +19 -0
- package/dist/harbor/http.js +177 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +0 -0
- package/dist/harbor/quo.d.ts +10 -0
- package/dist/harbor/quo.js +42 -0
- package/dist/harbor/resolve.d.ts +16 -0
- package/dist/harbor/resolve.js +17 -0
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/harbor/sealed.d.ts +35 -0
- package/dist/harbor/sealed.js +162 -0
- package/dist/harbor/tauri.d.ts +16 -0
- package/dist/harbor/tauri.js +59 -0
- package/dist/harbor/ward.d.ts +9 -0
- package/dist/harbor/ward.js +15 -0
- package/dist/human/app.d.ts +32 -0
- package/dist/human/app.js +527 -0
- package/dist/human/apps.d.ts +10 -0
- package/dist/human/apps.js +29 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/door.d.ts +19 -0
- package/dist/human/door.js +92 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +33 -0
- package/dist/human/html.d.ts +55 -0
- package/dist/human/html.js +451 -0
- package/dist/human/local.d.ts +7 -0
- package/dist/human/local.js +10 -0
- package/dist/human/mark.d.ts +8 -0
- package/dist/human/mark.js +47 -0
- package/dist/human/move.d.ts +17 -0
- package/dist/human/move.js +90 -0
- package/dist/human/screen.d.ts +19 -0
- package/dist/human/screen.js +119 -0
- package/dist/human/style.d.ts +2 -0
- package/dist/human/style.js +60 -0
- package/dist/human/tab.bundle.txt +4775 -0
- package/dist/human/tab.d.ts +15 -0
- package/dist/human/tab.js +409 -0
- package/dist/human/tree.d.ts +50 -0
- package/dist/human/tree.js +157 -0
- package/dist/human/wake.d.ts +34 -0
- package/dist/human/wake.js +179 -0
- package/dist/human/web.d.ts +33 -0
- package/dist/human/web.js +184 -0
- package/dist/human/worker.bundle.txt +3868 -0
- package/dist/human/worker.d.ts +1 -0
- package/dist/human/worker.js +81 -0
- package/dist/human/worlds.d.ts +24 -0
- package/dist/human/worlds.js +99 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/allow.d.ts +20 -0
- package/dist/mcp/allow.js +88 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +92 -0
- package/dist/mcp/oauth.d.ts +91 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/route.d.ts +15 -0
- package/dist/mcp/route.js +42 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +99 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +102 -0
- package/dock/agents.ts +121 -0
- package/dock/apns.ts +138 -0
- package/dock/console.ts +23 -0
- package/dock/dialers.ts +85 -0
- package/dock/faculty.ts +148 -0
- package/dock/fcm.ts +103 -0
- package/dock/grants.ts +111 -0
- package/dock/http2.ts +70 -0
- package/dock/index.ts +133 -0
- package/dock/local.ts +48 -0
- package/dock/location.ts +22 -0
- package/dock/notifications.ts +67 -0
- package/dock/push.ts +161 -0
- package/dock/quo-app-bells.md +280 -0
- package/dock/quo-dock.md +1942 -0
- package/dock/service.ts +100 -0
- package/dock/timer.ts +174 -0
- package/dock/webpush.ts +182 -0
- package/harbor/browser.ts +97 -0
- package/harbor/capacitor.ts +223 -0
- package/harbor/disk.ts +237 -0
- package/harbor/edge/alarm.ts +48 -0
- package/harbor/edge/edge.ts +407 -0
- package/harbor/edge/exercise.ts +241 -0
- package/harbor/edge/given.ts +106 -0
- package/harbor/edge/platform.d.ts +39 -0
- package/harbor/edge/storage.ts +179 -0
- package/harbor/edge/text.d.ts +15 -0
- package/harbor/edge/worker.ts +70 -0
- package/harbor/edge/wrangler.toml +30 -0
- package/harbor/files.ts +221 -0
- package/harbor/http.ts +188 -0
- package/harbor/idb.ts +0 -0
- package/harbor/quo-harbor.md +850 -0
- package/harbor/quo.ts +44 -0
- package/harbor/resolve.ts +31 -0
- package/harbor/seal.ts +26 -0
- package/harbor/sealed.ts +183 -0
- package/harbor/tauri.ts +79 -0
- package/harbor/ward.ts +28 -0
- package/human/app.ts +580 -0
- package/human/apps.ts +47 -0
- package/human/dom.ts +21 -0
- package/human/door.ts +113 -0
- package/human/guest.ts +34 -0
- package/human/html.ts +490 -0
- package/human/local.ts +30 -0
- package/human/mark.ts +53 -0
- package/human/move.ts +122 -0
- package/human/quo-human.md +1042 -0
- package/human/screen.ts +136 -0
- package/human/style.ts +63 -0
- package/human/tab.bundle.txt +4775 -0
- package/human/tab.ts +446 -0
- package/human/tree.ts +217 -0
- package/human/wake.ts +191 -0
- package/human/web.ts +259 -0
- package/human/worker.bundle.txt +3868 -0
- package/human/worker.ts +124 -0
- package/human/worlds.ts +127 -0
- package/mcp/agent.ts +94 -0
- package/mcp/allow.ts +131 -0
- package/mcp/http.ts +92 -0
- package/mcp/oauth.ts +270 -0
- package/mcp/quo-mcp.md +343 -0
- package/mcp/route.ts +45 -0
- package/mcp/runner.ts +136 -0
- package/mcp/server.ts +101 -0
- package/package.json +121 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// OAuth on the mcp route: the wire protocol a remote MCP client speaks to
|
|
3
|
+
// end with a grant. An authorization server small enough to read, and
|
|
4
|
+
// nothing a client does not need: metadata, dynamic registration, PKCE with
|
|
5
|
+
// S256, the code grant, refresh, opaque tokens. It is a route, not Quo. Its
|
|
6
|
+
// one job is to end with a client identity, which the front desk trades for
|
|
7
|
+
// an invitation and the avatar knocks with; the grant it issues is the
|
|
8
|
+
// box's, `dock/grants.ts`, honoured on every route, and what this file
|
|
9
|
+
// keeps for itself is only the dance: clients, pending requests and codes.
|
|
10
|
+
// No secret of Quo's is ever here.
|
|
11
|
+
//
|
|
12
|
+
// GET /.well-known/oauth-authorization-server who issues, and where
|
|
13
|
+
// GET /.well-known/oauth-protected-resource[/mcp] which issuer guards the MCP endpoint
|
|
14
|
+
// POST /register a client names itself and its redirects
|
|
15
|
+
// GET /authorize starts a request; sent to the web route to finish
|
|
16
|
+
// POST /token code or refresh for an access token
|
|
17
|
+
//
|
|
18
|
+
// The web route finishes a request by calling `complete(request, identity)`
|
|
19
|
+
// after its allow page, where the human hands the invitation the root minted;
|
|
20
|
+
// that returns the redirect that carries the code back to the client.
|
|
21
|
+
import { REFRESH_TTL } from '../dock/grants.js';
|
|
22
|
+
export { ACCESS_TTL, REFRESH_TTL } from '../dock/grants.js';
|
|
23
|
+
// A code, a client id: every key here is a string the far side chose, and a
|
|
24
|
+
// bare lookup would find `__proto__` and hand back an object with no
|
|
25
|
+
// identity. Own keys only.
|
|
26
|
+
const own = (rec, key) => (Object.hasOwn(rec, key) ? rec[key] : undefined);
|
|
27
|
+
export const CODE_TTL = 10 * 60 * 1000; // a code, and a pending request, live ten minutes
|
|
28
|
+
export const emptyDance = () => ({ clients: {}, pending: {}, codes: {} });
|
|
29
|
+
// The randomness and the hash are the web crypto every runtime has, so the
|
|
30
|
+
// dance runs on a worker as it runs on a daemon: nothing of node is named in
|
|
31
|
+
// this route, and a terrain only says where the dance is kept.
|
|
32
|
+
const b64url = (bytes) => {
|
|
33
|
+
let binary = '';
|
|
34
|
+
for (const b of bytes)
|
|
35
|
+
binary += String.fromCharCode(b);
|
|
36
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
37
|
+
};
|
|
38
|
+
const token = () => b64url(globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
39
|
+
const s256 = async (v) => b64url(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(v))));
|
|
40
|
+
export class OAuth {
|
|
41
|
+
o;
|
|
42
|
+
dance;
|
|
43
|
+
// The dance is in hand from the first request, so every lookup here is
|
|
44
|
+
// plain: `open` is where the terrain is waited on, once.
|
|
45
|
+
static async open(o) {
|
|
46
|
+
return new OAuth(o, await o.store.load());
|
|
47
|
+
}
|
|
48
|
+
constructor(o, dance) {
|
|
49
|
+
this.o = o;
|
|
50
|
+
this.dance = dance;
|
|
51
|
+
}
|
|
52
|
+
keep() {
|
|
53
|
+
return this.o.store.save(this.dance);
|
|
54
|
+
}
|
|
55
|
+
now() {
|
|
56
|
+
return this.o.now?.() ?? this.o.grants.now();
|
|
57
|
+
}
|
|
58
|
+
metadata() {
|
|
59
|
+
const i = this.o.issuer;
|
|
60
|
+
return {
|
|
61
|
+
issuer: i,
|
|
62
|
+
authorization_endpoint: `${i}/authorize`,
|
|
63
|
+
token_endpoint: `${i}/token`,
|
|
64
|
+
registration_endpoint: `${i}/register`,
|
|
65
|
+
response_types_supported: ['code'],
|
|
66
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
67
|
+
code_challenge_methods_supported: ['S256'],
|
|
68
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
69
|
+
scopes_supported: ['quo'],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
protectedResource() {
|
|
73
|
+
return { resource: this.o.resource, authorization_servers: [this.o.issuer], bearer_methods_supported: ['header'] };
|
|
74
|
+
}
|
|
75
|
+
async register(body) {
|
|
76
|
+
const b = body;
|
|
77
|
+
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
|
|
78
|
+
if (uris.length === 0)
|
|
79
|
+
return { error: 'invalid_redirect_uri' };
|
|
80
|
+
const 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 };
|
|
81
|
+
this.dance.clients[client.client_id] = client;
|
|
82
|
+
this.sweep();
|
|
83
|
+
await this.keep();
|
|
84
|
+
const { exp: _, ...shown } = client;
|
|
85
|
+
return shown;
|
|
86
|
+
}
|
|
87
|
+
// A client still alive, by id.
|
|
88
|
+
client(id) {
|
|
89
|
+
const c = own(this.dance.clients, id);
|
|
90
|
+
return c && c.exp >= this.now() ? c : undefined;
|
|
91
|
+
}
|
|
92
|
+
// Start a request. What comes back is where to send the browser: the web
|
|
93
|
+
// route with the request id, or the client's redirect with an error.
|
|
94
|
+
async authorize(q) {
|
|
95
|
+
const client = this.client(q.get('client_id') ?? '');
|
|
96
|
+
const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
|
|
97
|
+
if (!client || redirect === null || !client.redirect_uris.includes(redirect))
|
|
98
|
+
return { error: 'invalid_client' };
|
|
99
|
+
const back = (error) => ({ redirect: withQuery(redirect, { error, state: q.get('state') }) });
|
|
100
|
+
if (q.get('response_type') !== 'code')
|
|
101
|
+
return back('unsupported_response_type');
|
|
102
|
+
const challenge = q.get('code_challenge');
|
|
103
|
+
if (!challenge || q.get('code_challenge_method') !== 'S256')
|
|
104
|
+
return back('invalid_request');
|
|
105
|
+
const id = token();
|
|
106
|
+
this.dance.pending[id] = { client_id: client.client_id, redirect_uri: redirect, challenge, state: q.get('state'), resource: q.get('resource'), exp: this.now() + CODE_TTL };
|
|
107
|
+
this.sweep();
|
|
108
|
+
await this.keep();
|
|
109
|
+
return { redirect: this.o.finish(id) };
|
|
110
|
+
}
|
|
111
|
+
// What the web route shows: who is asking. Null if the request is gone.
|
|
112
|
+
pending(id) {
|
|
113
|
+
const p = own(this.dance.pending, id);
|
|
114
|
+
if (!p || p.exp < this.now())
|
|
115
|
+
return null;
|
|
116
|
+
const client = this.client(p.client_id);
|
|
117
|
+
return client ? { ...p, client } : null;
|
|
118
|
+
}
|
|
119
|
+
// The web route's last step: the human allowed this client as this
|
|
120
|
+
// identity, into this world.
|
|
121
|
+
async complete(id, identity, ward) {
|
|
122
|
+
const p = this.pending(id);
|
|
123
|
+
if (!p)
|
|
124
|
+
return { error: 'expired' };
|
|
125
|
+
delete this.dance.pending[id];
|
|
126
|
+
const code = token();
|
|
127
|
+
this.dance.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
|
|
128
|
+
await this.keep();
|
|
129
|
+
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
130
|
+
}
|
|
131
|
+
// The human refused, or the request died: the client hears that.
|
|
132
|
+
async deny(id) {
|
|
133
|
+
const p = this.pending(id);
|
|
134
|
+
if (!p)
|
|
135
|
+
return { error: 'expired' };
|
|
136
|
+
delete this.dance.pending[id];
|
|
137
|
+
await this.keep();
|
|
138
|
+
return { redirect: withQuery(p.redirect_uri, { error: 'access_denied', state: p.state }) };
|
|
139
|
+
}
|
|
140
|
+
async token(body) {
|
|
141
|
+
const grant = body.get('grant_type');
|
|
142
|
+
if (grant === 'authorization_code') {
|
|
143
|
+
const c = own(this.dance.codes, body.get('code') ?? '');
|
|
144
|
+
if (!c || c.exp < this.now())
|
|
145
|
+
return { error: 'invalid_grant' };
|
|
146
|
+
delete this.dance.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 ((await 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.keep();
|
|
150
|
+
return { error: 'invalid_grant' };
|
|
151
|
+
}
|
|
152
|
+
return this.issue(c.identity, c.ward, c.client_id);
|
|
153
|
+
}
|
|
154
|
+
if (grant === 'refresh_token') {
|
|
155
|
+
const t = await this.o.grants.refresh(body.get('refresh_token') ?? '');
|
|
156
|
+
return t ?? { error: 'invalid_grant' };
|
|
157
|
+
}
|
|
158
|
+
return { error: 'unsupported_grant_type' };
|
|
159
|
+
}
|
|
160
|
+
// The dance ends: the box issues the grant, and the client lives as long
|
|
161
|
+
// as what it was granted.
|
|
162
|
+
async issue(identity, ward, client_id) {
|
|
163
|
+
const t = await this.o.grants.issue(identity, ward, client_id);
|
|
164
|
+
const c = own(this.dance.clients, client_id);
|
|
165
|
+
if (c)
|
|
166
|
+
c.exp = this.now() + REFRESH_TTL;
|
|
167
|
+
this.sweep();
|
|
168
|
+
await this.keep();
|
|
169
|
+
return t;
|
|
170
|
+
}
|
|
171
|
+
sweep() {
|
|
172
|
+
const now = this.now();
|
|
173
|
+
for (const k of ['clients', 'pending', 'codes'])
|
|
174
|
+
for (const [t, g] of Object.entries(this.dance[k]))
|
|
175
|
+
if (!(g.exp >= now))
|
|
176
|
+
delete this.dance[k][t];
|
|
177
|
+
}
|
|
178
|
+
// The HTTP face. `rest` is the path under the route, and a path that is
|
|
179
|
+
// none of the dance's is answered nothing, which is how a handler declines.
|
|
180
|
+
async handle(req, rest) {
|
|
181
|
+
const url = new URL(req.url);
|
|
182
|
+
const json = (status, body) => Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
|
|
183
|
+
if (rest === '/.well-known/oauth-authorization-server' && req.method === 'GET')
|
|
184
|
+
return json(200, this.metadata());
|
|
185
|
+
if ((rest === '/.well-known/oauth-protected-resource' || rest === '/.well-known/oauth-protected-resource/mcp') && req.method === 'GET')
|
|
186
|
+
return json(200, this.protectedResource());
|
|
187
|
+
if (rest === '/register' && req.method === 'POST') {
|
|
188
|
+
const out = await this.register(await readJson(req));
|
|
189
|
+
return json('error' in out ? 400 : 201, out);
|
|
190
|
+
}
|
|
191
|
+
if (rest === '/authorize' && req.method === 'GET') {
|
|
192
|
+
const out = await this.authorize(url.searchParams);
|
|
193
|
+
if ('error' in out)
|
|
194
|
+
return json(400, out);
|
|
195
|
+
return new Response(null, { status: 302, headers: { location: out.redirect, 'cache-control': 'no-store' } });
|
|
196
|
+
}
|
|
197
|
+
if (rest === '/token' && req.method === 'POST') {
|
|
198
|
+
const out = await this.token(await readForm(req));
|
|
199
|
+
return json('error' in out ? 400 : 200, out);
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
// What an unauthenticated MCP request is told: where to go.
|
|
204
|
+
challenge() {
|
|
205
|
+
return Response.json({ error: 'unauthorized' }, { status: 401, headers: { 'www-authenticate': `Bearer resource_metadata="${this.o.issuer}/.well-known/oauth-protected-resource/mcp"` } });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function withQuery(base, params) {
|
|
209
|
+
const u = new URL(base);
|
|
210
|
+
for (const [k, v] of Object.entries(params))
|
|
211
|
+
if (v !== null)
|
|
212
|
+
u.searchParams.set(k, v);
|
|
213
|
+
return u.toString();
|
|
214
|
+
}
|
|
215
|
+
export async function readBody(req, limit = 64 * 1024) {
|
|
216
|
+
const s = await req.text();
|
|
217
|
+
if (s.length > limit)
|
|
218
|
+
throw new Error('too large');
|
|
219
|
+
return s;
|
|
220
|
+
}
|
|
221
|
+
async function readJson(req) {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(await readBody(req));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export async function readForm(req) {
|
|
230
|
+
const body = await readBody(req);
|
|
231
|
+
if ((req.headers.get('content-type') ?? '').includes('application/json')) {
|
|
232
|
+
try {
|
|
233
|
+
const o = JSON.parse(body);
|
|
234
|
+
return new URLSearchParams(Object.entries(o).map(([k, v]) => [k, String(v)]));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return new URLSearchParams();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return new URLSearchParams(body);
|
|
241
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { OAuth, type Store } from './oauth.ts';
|
|
2
|
+
import type { McpHttp } from './http.ts';
|
|
3
|
+
import type { Handler } from '../harbor/http.ts';
|
|
4
|
+
import type { Grants } from '../dock/grants.ts';
|
|
5
|
+
export type Routes = {
|
|
6
|
+
mcp: string;
|
|
7
|
+
web: string;
|
|
8
|
+
quo?: string;
|
|
9
|
+
api?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function mcpRoute(store: Store, routes: Routes, mcp: McpHttp | null, grants: Grants): Promise<{
|
|
12
|
+
oauth: OAuth;
|
|
13
|
+
handler: Handler;
|
|
14
|
+
wellKnown: Handler;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The mcp. route: the OAuth dance and the MCP endpoint, served by the
|
|
3
|
+
// daemon under `/mcp` and mapped by the proxy from the mcp. hostname. The
|
|
4
|
+
// dance's own state, clients, pending requests and codes, each living
|
|
5
|
+
// minutes, is kept in a store the terrain hands in: a file for the daemon,
|
|
6
|
+
// the object's storage on the edge. Where it ends is the box's grants, which
|
|
7
|
+
// every route honours; a bearer names an identity in a world, and the
|
|
8
|
+
// endpoint hands both to the model side, which runs beside her avatar.
|
|
9
|
+
import { OAuth } from './oauth.js';
|
|
10
|
+
// `wellKnown` is the same discovery at the origin's root, for a box whose
|
|
11
|
+
// routes are paths under one origin rather than hostnames: an issuer with
|
|
12
|
+
// a path is looked up at `/.well-known/<document>` followed by that path,
|
|
13
|
+
// which no route's prefix can catch, so the daemon mounts this at
|
|
14
|
+
// `/.well-known`. Under a hostname per route the issuer has no path and
|
|
15
|
+
// the route's own `.well-known` is the root's already.
|
|
16
|
+
export async function mcpRoute(store, routes, mcp, grants) {
|
|
17
|
+
const oauth = await OAuth.open({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/allow?request=${id}`, store, grants });
|
|
18
|
+
// The dance first, then the endpoint: both answer a `Response`, and the
|
|
19
|
+
// dance declines a path that is not one of its own by answering nothing.
|
|
20
|
+
const handler = async (req, rest) => {
|
|
21
|
+
const danced = await oauth.handle(req, rest);
|
|
22
|
+
if (danced)
|
|
23
|
+
return danced;
|
|
24
|
+
if (rest === '/mcp') {
|
|
25
|
+
const grant = grants.bearer(req);
|
|
26
|
+
if (grant === null || !mcp)
|
|
27
|
+
return oauth.challenge();
|
|
28
|
+
return mcp.handle(req, grant.identity, grant.ward);
|
|
29
|
+
}
|
|
30
|
+
return Response.json({ error: 'no such route' }, { status: 404 });
|
|
31
|
+
};
|
|
32
|
+
const issuerPath = new URL(routes.mcp).pathname.replace(/\/$/, '');
|
|
33
|
+
const wellKnown = (req, rest) => {
|
|
34
|
+
const json = (body) => Response.json(body, { headers: { 'access-control-allow-origin': '*' } });
|
|
35
|
+
if (req.method === 'GET' && rest === `/oauth-authorization-server${issuerPath}`)
|
|
36
|
+
return json(oauth.metadata());
|
|
37
|
+
if (req.method === 'GET' && (rest === `/oauth-protected-resource${issuerPath}` || rest === `/oauth-protected-resource${issuerPath}/mcp`))
|
|
38
|
+
return json(oauth.protectedResource());
|
|
39
|
+
return Response.json({ error: 'no such route' }, { status: 404 });
|
|
40
|
+
};
|
|
41
|
+
return { oauth, handler, wellKnown };
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Blueprint, JsonObject } from '@nervur-org/nervur';
|
|
2
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
3
|
+
import { type Serving } from '../beings/side.ts';
|
|
4
|
+
export type Model = {
|
|
5
|
+
url: string;
|
|
6
|
+
model: string;
|
|
7
|
+
key?: string;
|
|
8
|
+
turns?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare const TURNS = 10;
|
|
11
|
+
export type Tool = {
|
|
12
|
+
type: 'function';
|
|
13
|
+
function: {
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
parameters: JsonObject;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export type ToolCall = {
|
|
20
|
+
id: string;
|
|
21
|
+
type: 'function';
|
|
22
|
+
function: {
|
|
23
|
+
name: string;
|
|
24
|
+
arguments: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export type Message = {
|
|
28
|
+
role: 'system' | 'user';
|
|
29
|
+
content: string;
|
|
30
|
+
} | {
|
|
31
|
+
role: 'assistant';
|
|
32
|
+
content: string | null;
|
|
33
|
+
tool_calls?: ToolCall[];
|
|
34
|
+
} | {
|
|
35
|
+
role: 'tool';
|
|
36
|
+
tool_call_id: string;
|
|
37
|
+
content: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function tools(bp: Blueprint): Tool[];
|
|
40
|
+
export declare function result(avatar: Avatar, call: ToolCall): Promise<string>;
|
|
41
|
+
export type Runner = Serving & {
|
|
42
|
+
say(text: string): Promise<string | null>;
|
|
43
|
+
readonly messages: readonly Message[];
|
|
44
|
+
};
|
|
45
|
+
export declare function runnerSide(avatar: Avatar, model: Model, hear?: (text: string) => void, after?: () => Promise<void>): Runner;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
2
|
+
import { presentation } from '../beings/look.js';
|
|
3
|
+
export const TURNS = 10;
|
|
4
|
+
// Her describe, spoken as a tools array. Name, description and input are
|
|
5
|
+
// verbatim, with the one narrowing an endpoint has asked for: an ask that
|
|
6
|
+
// declares no properties is sent with an empty `properties`, because
|
|
7
|
+
// LM Studio refuses a parameters schema without one. Her look, her page and
|
|
8
|
+
// every carried page are presentation, for a screen, and are not functions.
|
|
9
|
+
export function tools(bp) {
|
|
10
|
+
const shown = presentation(bp);
|
|
11
|
+
return bp.asks
|
|
12
|
+
.filter((a) => !shown.has(a.name))
|
|
13
|
+
.map((a) => {
|
|
14
|
+
const t = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
|
|
15
|
+
if (a.description !== undefined)
|
|
16
|
+
t.function.description = a.description;
|
|
17
|
+
return t;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
// One tool call, one ask, one tool message: the three words cross as the
|
|
21
|
+
// table in quo-mcp.md says, and a tool message is text, so the two words
|
|
22
|
+
// that carry nothing cross as the text the trunk gives them.
|
|
23
|
+
export async function result(avatar, call) {
|
|
24
|
+
let args = {};
|
|
25
|
+
try {
|
|
26
|
+
const parsed = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
27
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
28
|
+
args = parsed;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return JSON.stringify({ error: 'arguments are not JSON' });
|
|
32
|
+
}
|
|
33
|
+
const w = word(await avatar.call(call.function.name, args));
|
|
34
|
+
if (w.word === 'object' || w.word === 'error')
|
|
35
|
+
return JSON.stringify(w.value);
|
|
36
|
+
return w.word === 'silence' ? SILENCE_TEXT : w.word === 'quo' ? wordText(w.name) : UNREACHED_TEXT;
|
|
37
|
+
}
|
|
38
|
+
// `hear` gets every final text the model says, whoever started the turn.
|
|
39
|
+
// `after` runs when an ask is done, as in the MCP server: a harbor that must
|
|
40
|
+
// write what the ward changed hooks it.
|
|
41
|
+
export function runnerSide(avatar, model, hear = () => { }, after = async () => { }) {
|
|
42
|
+
const messages = [];
|
|
43
|
+
const ceiling = model.turns ?? TURNS;
|
|
44
|
+
let busy = Promise.resolve();
|
|
45
|
+
async function complete(t) {
|
|
46
|
+
const headers = { 'content-type': 'application/json' };
|
|
47
|
+
if (model.key !== undefined)
|
|
48
|
+
headers.authorization = `Bearer ${model.key}`;
|
|
49
|
+
const res = await fetch(`${model.url.replace(/\/$/, '')}/chat/completions`, { method: 'POST', headers, body: JSON.stringify({ model: model.model, messages, tools: t }) });
|
|
50
|
+
const body = (await res.json());
|
|
51
|
+
const m = body.choices?.[0]?.message;
|
|
52
|
+
if (!res.ok || !m)
|
|
53
|
+
throw new Error(`model: ${res.status} ${JSON.stringify(body.error ?? body)}`);
|
|
54
|
+
return m;
|
|
55
|
+
}
|
|
56
|
+
// One turn: from the last appended message to the model's final text.
|
|
57
|
+
async function turn() {
|
|
58
|
+
const bp = await avatar.tools();
|
|
59
|
+
const t = 'asks' in bp ? tools(bp) : [];
|
|
60
|
+
for (let i = 0; i < ceiling; i++) {
|
|
61
|
+
const m = await complete(t);
|
|
62
|
+
const reply = { role: 'assistant', content: m.content };
|
|
63
|
+
if (m.tool_calls?.length)
|
|
64
|
+
reply.tool_calls = m.tool_calls;
|
|
65
|
+
messages.push(reply);
|
|
66
|
+
if (!m.tool_calls?.length) {
|
|
67
|
+
const text = m.content ?? '';
|
|
68
|
+
hear(text);
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
for (const call of m.tool_calls) {
|
|
72
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: await result(avatar, call) });
|
|
73
|
+
await after();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
// Turns never overlap: one conversation, one train of thought.
|
|
79
|
+
function start(message) {
|
|
80
|
+
const next = busy.then(async () => {
|
|
81
|
+
messages.push(message);
|
|
82
|
+
return turn();
|
|
83
|
+
});
|
|
84
|
+
busy = next.catch(() => { });
|
|
85
|
+
return next;
|
|
86
|
+
}
|
|
87
|
+
const ear = (object) => {
|
|
88
|
+
void start({ role: 'user', content: JSON.stringify(object) }).catch(() => { });
|
|
89
|
+
};
|
|
90
|
+
avatar.ears.add(ear);
|
|
91
|
+
return {
|
|
92
|
+
messages,
|
|
93
|
+
say: (text) => start({ role: 'user', content: text }),
|
|
94
|
+
close: async () => {
|
|
95
|
+
avatar.ears.delete(ear);
|
|
96
|
+
await busy;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import type { Blueprint } from '@nervur-org/nervur';
|
|
4
|
+
import { type Serving, type Subject } from '../beings/side.ts';
|
|
5
|
+
import { type Look } from '../beings/look.ts';
|
|
6
|
+
export declare const NAME = "quo";
|
|
7
|
+
export declare const VERSION = "0.0.0";
|
|
8
|
+
export type { Subject } from '../beings/side.ts';
|
|
9
|
+
export declare const DESCRIBE: Tool;
|
|
10
|
+
export declare function tools(bp: Blueprint, look?: Look): Tool[];
|
|
11
|
+
export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
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
|
|
10
|
+
// SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
|
|
11
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
14
|
+
import { hintFor, presentation, sanitise } from '../beings/look.js';
|
|
15
|
+
import { isSilence, isWord, digest } from '@nervur-org/nervur';
|
|
16
|
+
export const NAME = 'quo';
|
|
17
|
+
export const VERSION = '0.0.0';
|
|
18
|
+
// The empty ask as a tool: her describe whole, asks and notes.
|
|
19
|
+
export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
20
|
+
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
21
|
+
// input are verbatim; an output schema crosses when she declared one. Her
|
|
22
|
+
// look, when she has one, is the hints: a title, and the annotations a host
|
|
23
|
+
// reads. Her look, her page and every carried page are presentation, for a
|
|
24
|
+
// screen, and are not tools.
|
|
25
|
+
export function tools(bp, look = {}) {
|
|
26
|
+
const shown = presentation(bp);
|
|
27
|
+
const asks = bp.asks
|
|
28
|
+
.filter((a) => !shown.has(a.name))
|
|
29
|
+
.map((a) => {
|
|
30
|
+
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
31
|
+
if (a.description !== undefined)
|
|
32
|
+
t.description = a.description;
|
|
33
|
+
if (a.output !== undefined)
|
|
34
|
+
t.outputSchema = { ...a.output, type: 'object' };
|
|
35
|
+
const h = hintFor(bp, look, a.name);
|
|
36
|
+
if (h.title !== undefined)
|
|
37
|
+
t.title = h.title;
|
|
38
|
+
const notes = {};
|
|
39
|
+
if (h.readOnly !== undefined)
|
|
40
|
+
notes.readOnlyHint = h.readOnly;
|
|
41
|
+
if (h.destructive !== undefined)
|
|
42
|
+
notes.destructiveHint = h.destructive;
|
|
43
|
+
if (h.idempotent !== undefined)
|
|
44
|
+
notes.idempotentHint = h.idempotent;
|
|
45
|
+
if (Object.keys(notes).length)
|
|
46
|
+
t.annotations = notes;
|
|
47
|
+
return t;
|
|
48
|
+
});
|
|
49
|
+
return [DESCRIBE, ...asks];
|
|
50
|
+
}
|
|
51
|
+
// `after` runs when a call is done: a harbor that must write what the ward
|
|
52
|
+
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
53
|
+
export async function mcpSide(avatar, transport, after = async () => { }) {
|
|
54
|
+
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
55
|
+
// Her look, asked once per digest of her describe.
|
|
56
|
+
let seen = null;
|
|
57
|
+
let look = {};
|
|
58
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
59
|
+
const bp = await avatar.tools();
|
|
60
|
+
if ('error' in bp && !('asks' in bp))
|
|
61
|
+
return { tools: [] }; // not joined: nothing to show, and nothing to call
|
|
62
|
+
const d = await digest(bp);
|
|
63
|
+
if (d !== seen) {
|
|
64
|
+
seen = d;
|
|
65
|
+
if (bp.asks.some((a) => a.name === 'look')) {
|
|
66
|
+
const l = await avatar.call('look', {});
|
|
67
|
+
look = isSilence(l) || isWord(l) ? {} : sanitise(l);
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
look = {};
|
|
71
|
+
}
|
|
72
|
+
return { tools: tools(bp, look) };
|
|
73
|
+
});
|
|
74
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
75
|
+
const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
76
|
+
await after();
|
|
77
|
+
if (w.word === 'object') {
|
|
78
|
+
const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
|
|
79
|
+
return { content: [{ type: 'text', text: JSON.stringify(w.value) }], ...structured };
|
|
80
|
+
}
|
|
81
|
+
if (w.word === 'error')
|
|
82
|
+
return { content: [{ type: 'text', text: JSON.stringify(w.value) }], structuredContent: w.value, isError: true };
|
|
83
|
+
if (w.word === 'silence')
|
|
84
|
+
return { content: [{ type: 'text', text: SILENCE_TEXT }], structuredContent: { error: 'silence' }, isError: true };
|
|
85
|
+
if (w.word === 'quo')
|
|
86
|
+
return { content: [{ type: 'text', text: wordText(w.name) }], structuredContent: { error: w.name }, isError: true };
|
|
87
|
+
return { content: [{ type: 'text', text: UNREACHED_TEXT }], structuredContent: { error: 'unreached' }, isError: true };
|
|
88
|
+
});
|
|
89
|
+
// A push from the user being is a notification to the client. If the
|
|
90
|
+
// transport is gone, the push is dropped, as the side says it is.
|
|
91
|
+
const ear = (object) => {
|
|
92
|
+
void server.sendLoggingMessage({ level: 'info', logger: NAME, data: object }).catch(() => { });
|
|
93
|
+
};
|
|
94
|
+
avatar.ears.add(ear);
|
|
95
|
+
await server.connect(transport);
|
|
96
|
+
return {
|
|
97
|
+
close: async () => {
|
|
98
|
+
avatar.ears.delete(ear);
|
|
99
|
+
await server.close();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|