@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { BrowserHarbor } from '../harbor/browser.js';
|
|
2
|
+
import { Avatar, USER } from '../beings/avatar.js';
|
|
3
|
+
import { screenSide } from './screen.js';
|
|
4
|
+
import { domSurface } from './dom.js';
|
|
5
|
+
const el = (tag, text = '', attrs = {}) => {
|
|
6
|
+
const e = document.createElement(tag);
|
|
7
|
+
if (text)
|
|
8
|
+
e.textContent = text;
|
|
9
|
+
for (const [k, v] of Object.entries(attrs))
|
|
10
|
+
e.setAttribute(k, v);
|
|
11
|
+
return e;
|
|
12
|
+
};
|
|
13
|
+
export async function start(cfg, root = document.body) {
|
|
14
|
+
const status = el('p', 'booting');
|
|
15
|
+
const screen = el('div');
|
|
16
|
+
root.append(status, screen);
|
|
17
|
+
const say = (s) => (status.textContent = s);
|
|
18
|
+
// The harbor in the tab: one database, one ward, one avatar, for good.
|
|
19
|
+
const harbor = new BrowserHarbor('quo');
|
|
20
|
+
await harbor.boot();
|
|
21
|
+
const main = harbor.wards.get('main') ?? (await harbor.create('main', 'me'));
|
|
22
|
+
if (!main.being('me'))
|
|
23
|
+
await main.ask('boot', { key: 'me', class: 'Avatar' });
|
|
24
|
+
const me = main.being('me');
|
|
25
|
+
harbor.dial(cfg.quo);
|
|
26
|
+
// The screen: every call rotates her keys and a same-ward ask never
|
|
27
|
+
// crosses the harbor, so the side saves after each one.
|
|
28
|
+
const show = async (notice) => {
|
|
29
|
+
status.remove();
|
|
30
|
+
await screenSide(me, domSurface(screen), () => main.save(), notice);
|
|
31
|
+
};
|
|
32
|
+
// Already in: reconnect, nothing minted.
|
|
33
|
+
const had = await me.tools();
|
|
34
|
+
if ('asks' in had)
|
|
35
|
+
return show('in, as before');
|
|
36
|
+
// The exchange, once: the owner password for a nonce, the nonce for an
|
|
37
|
+
// invitation the user being mints, the invitation for a standing.
|
|
38
|
+
const form = el('form');
|
|
39
|
+
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
40
|
+
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
41
|
+
const go = el('button', 'enter');
|
|
42
|
+
form.append(identity, password, go);
|
|
43
|
+
root.insertBefore(form, screen);
|
|
44
|
+
say(`not in (${had.error}): the owner password opens the world`);
|
|
45
|
+
form.onsubmit = async (ev) => {
|
|
46
|
+
ev.preventDefault();
|
|
47
|
+
say('asking the world');
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return say(`the world did not answer: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
return say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
|
|
57
|
+
const { nonce, ward } = (await res.json());
|
|
58
|
+
password.value = '';
|
|
59
|
+
say('knocking');
|
|
60
|
+
const got = await me.enter({ ward }, { kind: 'tab', nonce });
|
|
61
|
+
await main.save(); // the knock went through her own door, which the harbor never sees
|
|
62
|
+
if (!('asks' in got))
|
|
63
|
+
return say(`not in: ${got.error}`);
|
|
64
|
+
form.remove();
|
|
65
|
+
await show(`in, as ${identity.value}`);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export { USER };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Json, JsonObject } from '@quo-systems/quo';
|
|
2
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
3
|
+
import { type Serving } from '../beings/side.ts';
|
|
4
|
+
export type Run = (event: JsonObject) => Promise<Json>;
|
|
5
|
+
export declare const REPORT = "report";
|
|
6
|
+
export declare function processRun(command: string, args: string[], dir: string): Run;
|
|
7
|
+
export type Agent = Serving & {
|
|
8
|
+
idle(): Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
export declare function agentSide(avatar: Avatar, run: Run, after?: () => Promise<void>): Agent;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The model side as an event-driven agent: the world is the loop. The user
|
|
3
|
+
// being holds a standing to the agent's avatar and asks her when something
|
|
4
|
+
// happens; that ask is a push, and her `notify` answers at once, so the ask
|
|
5
|
+
// is never held open. The push lands in her inbox, which is the queue, in
|
|
6
|
+
// her cells. This side drains the inbox one event at a time: it starts a
|
|
7
|
+
// run with the event, and when the run ends she asks the user being back on
|
|
8
|
+
// her own standing with the result. One run at a time, in the order the
|
|
9
|
+
// events came, across a restart too, because the queue is in the partition
|
|
10
|
+
// and not in this process. What a run may do is the gate; how it behaves
|
|
11
|
+
// is its constitution, the folder it runs in.
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
14
|
+
// The ask on the user being that takes the result. Gated to devices; the
|
|
15
|
+
// agent's client identity is what the gate reads.
|
|
16
|
+
export const REPORT = 'report';
|
|
17
|
+
// `claude -p` or any CLI agent: the command runs in the agent's folder with
|
|
18
|
+
// the event, as JSON, as its last argument. The result is what it printed.
|
|
19
|
+
// A non-zero exit is an error object with what it said on stderr.
|
|
20
|
+
export function processRun(command, args, dir) {
|
|
21
|
+
return (event) => new Promise((ok) => {
|
|
22
|
+
const child = spawn(command, [...args, JSON.stringify(event)], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
23
|
+
let out = '';
|
|
24
|
+
let err = '';
|
|
25
|
+
child.stdout.on('data', (c) => (out += c.toString()));
|
|
26
|
+
child.stderr.on('data', (c) => (err += c.toString()));
|
|
27
|
+
child.on('error', (e) => ok({ error: e.message }));
|
|
28
|
+
child.on('close', (code) => ok(code === 0 ? { output: out.trim() } : { error: `exit ${code}`, stderr: err.trim() }));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// `after` runs when her cells changed and when an ask is done, as in the
|
|
32
|
+
// other sides: a harbor that must write what the ward changed hooks it.
|
|
33
|
+
export function agentSide(avatar, run, after = async () => { }) {
|
|
34
|
+
let draining = Promise.resolve();
|
|
35
|
+
let open = true;
|
|
36
|
+
const inbox = () => avatar.cells.inbox;
|
|
37
|
+
async function one(event) {
|
|
38
|
+
let result;
|
|
39
|
+
try {
|
|
40
|
+
result = await run(event);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
result = { error: e instanceof Error ? e.message : String(e) };
|
|
44
|
+
}
|
|
45
|
+
// The report is an ask like any other. Silence and unreached are told
|
|
46
|
+
// apart and dropped: a report is never retried, and never held.
|
|
47
|
+
const w = word(await avatar.call(REPORT, { event, result }));
|
|
48
|
+
if (w.word === 'silence' || w.word === 'unreached' || w.word === 'quo')
|
|
49
|
+
say({ dropped: w.word === 'silence' ? SILENCE_TEXT : w.word === 'quo' ? wordText(w.name) : UNREACHED_TEXT });
|
|
50
|
+
await after();
|
|
51
|
+
}
|
|
52
|
+
// One at a time, first in first out, until the inbox is empty.
|
|
53
|
+
function drain() {
|
|
54
|
+
draining = draining.then(async () => {
|
|
55
|
+
while (open && inbox().length > 0) {
|
|
56
|
+
const event = inbox().shift();
|
|
57
|
+
await after(); // the event left the queue before the run starts: a crash mid-run loses it, and never runs it twice
|
|
58
|
+
await one(event);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return draining;
|
|
62
|
+
}
|
|
63
|
+
// A dropped report is told to her other sides, if any: the agent itself has no ear for it.
|
|
64
|
+
const say = (object) => { for (const ear of avatar.ears)
|
|
65
|
+
if (ear !== ear0)
|
|
66
|
+
ear(object); };
|
|
67
|
+
const ear0 = () => void drain();
|
|
68
|
+
avatar.ears.add(ear0);
|
|
69
|
+
void drain(); // whatever waited while no side was up
|
|
70
|
+
return {
|
|
71
|
+
idle: () => draining,
|
|
72
|
+
close: async () => {
|
|
73
|
+
open = false;
|
|
74
|
+
avatar.ears.delete(ear0);
|
|
75
|
+
await draining;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
4
|
+
import type { Serving } from '../beings/side.ts';
|
|
5
|
+
export type Session = {
|
|
6
|
+
identity: string;
|
|
7
|
+
transport: StreamableHTTPServerTransport;
|
|
8
|
+
serving: Serving;
|
|
9
|
+
touched: number;
|
|
10
|
+
};
|
|
11
|
+
export declare const SESSION_IDLE: number;
|
|
12
|
+
export type Resolve = (identity: string) => Promise<{
|
|
13
|
+
avatar?: Avatar;
|
|
14
|
+
error?: string;
|
|
15
|
+
}>;
|
|
16
|
+
export declare class McpHttp {
|
|
17
|
+
readonly sessions: Map<string, Session>;
|
|
18
|
+
readonly resolve: Resolve;
|
|
19
|
+
readonly after: () => Promise<void>;
|
|
20
|
+
now: () => number;
|
|
21
|
+
gone: (identity: string) => Promise<void>;
|
|
22
|
+
constructor(resolve: Resolve, after?: () => Promise<void>);
|
|
23
|
+
handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void>;
|
|
24
|
+
sweep(): void;
|
|
25
|
+
drop(sid: string): void;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
package/dist/mcp/http.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The MCP endpoint on the mcp route: streamable HTTP, one session per
|
|
3
|
+
// client connection, one avatar per client identity. A bearer names the
|
|
4
|
+
// identity, the identity names the avatar, and the side from `server.ts`
|
|
5
|
+
// runs beside her exactly as it does over stdio. A session belongs to the
|
|
6
|
+
// identity that opened it; a bearer of another identity on the same session
|
|
7
|
+
// is refused. Sessions live in memory and die with the daemon, which is
|
|
8
|
+
// what a reconnect is for: the standing is the state, and it is on disk. A
|
|
9
|
+
// client that goes away without a DELETE leaves a session behind; one idle
|
|
10
|
+
// for an hour is dropped, since HTTP tells the server nothing else.
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
13
|
+
import { mcpSide } from './server.js';
|
|
14
|
+
export const SESSION_IDLE = 60 * 60 * 1000;
|
|
15
|
+
export class McpHttp {
|
|
16
|
+
sessions = new Map();
|
|
17
|
+
resolve;
|
|
18
|
+
after;
|
|
19
|
+
now = () => Date.now();
|
|
20
|
+
// What to do with an identity the user being has removed: the route's
|
|
21
|
+
// revoke, so the client's tokens go with the occupant. Set by whoever
|
|
22
|
+
// mounts the route beside the credential exchange.
|
|
23
|
+
gone = async () => { };
|
|
24
|
+
constructor(resolve, after = async () => { }) {
|
|
25
|
+
this.resolve = resolve;
|
|
26
|
+
this.after = after;
|
|
27
|
+
}
|
|
28
|
+
// `identity` is what the bearer named; the caller has already turned a
|
|
29
|
+
// stranger away. A request on a known session goes to it. A request with
|
|
30
|
+
// no session opens one, if it is an initialize; anything else is 400.
|
|
31
|
+
async handle(req, res, identity) {
|
|
32
|
+
const id = req.headers['mcp-session-id'];
|
|
33
|
+
const sid = Array.isArray(id) ? id[0] : id;
|
|
34
|
+
const json = (status, body) => {
|
|
35
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
36
|
+
res.end(JSON.stringify(body));
|
|
37
|
+
};
|
|
38
|
+
this.sweep();
|
|
39
|
+
if (sid !== undefined) {
|
|
40
|
+
const s = this.sessions.get(sid);
|
|
41
|
+
if (!s)
|
|
42
|
+
return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
|
|
43
|
+
if (s.identity !== identity)
|
|
44
|
+
return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
45
|
+
s.touched = this.now();
|
|
46
|
+
await s.transport.handleRequest(req, res);
|
|
47
|
+
if (req.method === 'DELETE')
|
|
48
|
+
this.drop(sid);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (req.method !== 'POST')
|
|
52
|
+
return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
|
|
53
|
+
const found = await this.resolve(identity);
|
|
54
|
+
// A session opens on her describe, and admit already asked it. The one
|
|
55
|
+
// word the door says for an identity the user being removed is
|
|
56
|
+
// `removed`, under the key it bound for her avatar: in MCP's vocabulary
|
|
57
|
+
// that is 401, the client drops its token and starts the exchange again,
|
|
58
|
+
// and the route forgets the grant.
|
|
59
|
+
if (found.error === 'removed') {
|
|
60
|
+
await this.gone(identity);
|
|
61
|
+
for (const [sid, s] of this.sessions)
|
|
62
|
+
if (s.identity === identity)
|
|
63
|
+
this.drop(sid);
|
|
64
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
65
|
+
return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
|
|
66
|
+
}
|
|
67
|
+
if (!found.avatar)
|
|
68
|
+
return json(500, { jsonrpc: '2.0', error: { code: -32002, message: found.error ?? 'no avatar' }, id: null });
|
|
69
|
+
const transport = new StreamableHTTPServerTransport({
|
|
70
|
+
sessionIdGenerator: () => randomUUID(),
|
|
71
|
+
onsessioninitialized: (s) => {
|
|
72
|
+
this.sessions.set(s, { identity, transport, serving, touched: this.now() });
|
|
73
|
+
},
|
|
74
|
+
onsessionclosed: (s) => this.drop(s),
|
|
75
|
+
});
|
|
76
|
+
const serving = await mcpSide(found.avatar, transport, this.after);
|
|
77
|
+
await transport.handleRequest(req, res);
|
|
78
|
+
}
|
|
79
|
+
sweep() {
|
|
80
|
+
const now = this.now();
|
|
81
|
+
for (const [sid, s] of this.sessions)
|
|
82
|
+
if (now - s.touched > SESSION_IDLE)
|
|
83
|
+
this.drop(sid);
|
|
84
|
+
}
|
|
85
|
+
drop(sid) {
|
|
86
|
+
const s = this.sessions.get(sid);
|
|
87
|
+
if (!s)
|
|
88
|
+
return;
|
|
89
|
+
this.sessions.delete(sid);
|
|
90
|
+
void s.serving.close().catch(() => { });
|
|
91
|
+
}
|
|
92
|
+
async close() {
|
|
93
|
+
for (const sid of [...this.sessions.keys()])
|
|
94
|
+
this.drop(sid);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export declare const CODE_TTL: number;
|
|
3
|
+
export declare const ACCESS_TTL: number;
|
|
4
|
+
export declare const REFRESH_TTL: number;
|
|
5
|
+
export type Client = {
|
|
6
|
+
client_id: string;
|
|
7
|
+
client_name: string;
|
|
8
|
+
redirect_uris: string[];
|
|
9
|
+
};
|
|
10
|
+
export type Pending = {
|
|
11
|
+
client_id: string;
|
|
12
|
+
redirect_uri: string;
|
|
13
|
+
challenge: string;
|
|
14
|
+
state: string | null;
|
|
15
|
+
resource: string | null;
|
|
16
|
+
exp: number;
|
|
17
|
+
};
|
|
18
|
+
export type Grant = {
|
|
19
|
+
identity: string;
|
|
20
|
+
client_id: string;
|
|
21
|
+
exp: number;
|
|
22
|
+
};
|
|
23
|
+
export type Store = {
|
|
24
|
+
clients: Record<string, Client>;
|
|
25
|
+
pending: Record<string, Pending>;
|
|
26
|
+
codes: Record<string, Pending & {
|
|
27
|
+
identity: string;
|
|
28
|
+
}>;
|
|
29
|
+
access: Record<string, Grant>;
|
|
30
|
+
refresh: Record<string, Grant>;
|
|
31
|
+
};
|
|
32
|
+
export declare const emptyStore: () => Store;
|
|
33
|
+
export type Options = {
|
|
34
|
+
issuer: string;
|
|
35
|
+
resource: string;
|
|
36
|
+
finish: (request: string) => string;
|
|
37
|
+
store: Store;
|
|
38
|
+
persist: (store: Store) => Promise<void>;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
};
|
|
41
|
+
export declare class OAuth {
|
|
42
|
+
readonly o: Options;
|
|
43
|
+
constructor(o: Options);
|
|
44
|
+
get store(): Store;
|
|
45
|
+
now(): number;
|
|
46
|
+
metadata(): {
|
|
47
|
+
issuer: string;
|
|
48
|
+
authorization_endpoint: string;
|
|
49
|
+
token_endpoint: string;
|
|
50
|
+
registration_endpoint: string;
|
|
51
|
+
response_types_supported: string[];
|
|
52
|
+
grant_types_supported: string[];
|
|
53
|
+
code_challenge_methods_supported: string[];
|
|
54
|
+
token_endpoint_auth_methods_supported: string[];
|
|
55
|
+
scopes_supported: string[];
|
|
56
|
+
};
|
|
57
|
+
protectedResource(): {
|
|
58
|
+
resource: string;
|
|
59
|
+
authorization_servers: string[];
|
|
60
|
+
bearer_methods_supported: string[];
|
|
61
|
+
};
|
|
62
|
+
register(body: unknown): Promise<Client | {
|
|
63
|
+
error: string;
|
|
64
|
+
}>;
|
|
65
|
+
authorize(q: URLSearchParams): Promise<{
|
|
66
|
+
redirect: string;
|
|
67
|
+
} | {
|
|
68
|
+
error: string;
|
|
69
|
+
}>;
|
|
70
|
+
pending(id: string): (Pending & {
|
|
71
|
+
client: Client;
|
|
72
|
+
}) | null;
|
|
73
|
+
complete(id: string, identity: string): Promise<{
|
|
74
|
+
redirect: string;
|
|
75
|
+
} | {
|
|
76
|
+
error: string;
|
|
77
|
+
}>;
|
|
78
|
+
deny(id: string): Promise<{
|
|
79
|
+
redirect: string;
|
|
80
|
+
} | {
|
|
81
|
+
error: string;
|
|
82
|
+
}>;
|
|
83
|
+
token(body: URLSearchParams): Promise<Record<string, unknown>>;
|
|
84
|
+
issue(identity: string, client_id: string): Promise<{
|
|
85
|
+
access_token: string;
|
|
86
|
+
token_type: string;
|
|
87
|
+
expires_in: number;
|
|
88
|
+
refresh_token: string;
|
|
89
|
+
scope: string;
|
|
90
|
+
}>;
|
|
91
|
+
bearer(req: IncomingMessage): string | null;
|
|
92
|
+
revoke(identity: string): Promise<void>;
|
|
93
|
+
sweep(): void;
|
|
94
|
+
handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
|
|
95
|
+
challenge(res: ServerResponse): void;
|
|
96
|
+
}
|
|
97
|
+
export declare function readBody(req: IncomingMessage, limit?: number): Promise<string>;
|
|
98
|
+
export declare function readForm(req: IncomingMessage): Promise<URLSearchParams>;
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
// A token, a code, a client id: every key here is a string the far side chose,
|
|
21
|
+
// and a bare lookup would find `__proto__` and hand back an object with no
|
|
22
|
+
// identity. Own keys only.
|
|
23
|
+
const own = (rec, key) => (Object.hasOwn(rec, key) ? rec[key] : undefined);
|
|
24
|
+
export const CODE_TTL = 10 * 60 * 1000; // a code, and a pending request, live ten minutes
|
|
25
|
+
export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
|
|
26
|
+
export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
|
|
27
|
+
export const emptyStore = () => ({ clients: {}, pending: {}, codes: {}, access: {}, refresh: {} });
|
|
28
|
+
const token = () => randomBytes(32).toString('base64url');
|
|
29
|
+
const s256 = (v) => createHash('sha256').update(v).digest('base64url');
|
|
30
|
+
export class OAuth {
|
|
31
|
+
o;
|
|
32
|
+
constructor(o) {
|
|
33
|
+
this.o = o;
|
|
34
|
+
}
|
|
35
|
+
get store() {
|
|
36
|
+
return this.o.store;
|
|
37
|
+
}
|
|
38
|
+
now() {
|
|
39
|
+
return this.o.now?.() ?? Date.now();
|
|
40
|
+
}
|
|
41
|
+
metadata() {
|
|
42
|
+
const i = this.o.issuer;
|
|
43
|
+
return {
|
|
44
|
+
issuer: i,
|
|
45
|
+
authorization_endpoint: `${i}/authorize`,
|
|
46
|
+
token_endpoint: `${i}/token`,
|
|
47
|
+
registration_endpoint: `${i}/register`,
|
|
48
|
+
response_types_supported: ['code'],
|
|
49
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
50
|
+
code_challenge_methods_supported: ['S256'],
|
|
51
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
52
|
+
scopes_supported: ['quo'],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
protectedResource() {
|
|
56
|
+
return { resource: this.o.resource, authorization_servers: [this.o.issuer], bearer_methods_supported: ['header'] };
|
|
57
|
+
}
|
|
58
|
+
async register(body) {
|
|
59
|
+
const b = body;
|
|
60
|
+
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
|
|
61
|
+
if (uris.length === 0)
|
|
62
|
+
return { error: 'invalid_redirect_uri' };
|
|
63
|
+
const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
|
|
64
|
+
this.store.clients[client.client_id] = client;
|
|
65
|
+
await this.o.persist(this.store);
|
|
66
|
+
return client;
|
|
67
|
+
}
|
|
68
|
+
// Start a request. What comes back is where to send the browser: the web
|
|
69
|
+
// route with the request id, or the client's redirect with an error.
|
|
70
|
+
async authorize(q) {
|
|
71
|
+
const client = own(this.store.clients, q.get('client_id') ?? '');
|
|
72
|
+
const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
|
|
73
|
+
if (!client || redirect === null || !client.redirect_uris.includes(redirect))
|
|
74
|
+
return { error: 'invalid_client' };
|
|
75
|
+
const back = (error) => ({ redirect: withQuery(redirect, { error, state: q.get('state') }) });
|
|
76
|
+
if (q.get('response_type') !== 'code')
|
|
77
|
+
return back('unsupported_response_type');
|
|
78
|
+
const challenge = q.get('code_challenge');
|
|
79
|
+
if (!challenge || q.get('code_challenge_method') !== 'S256')
|
|
80
|
+
return back('invalid_request');
|
|
81
|
+
const id = token();
|
|
82
|
+
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 };
|
|
83
|
+
this.sweep();
|
|
84
|
+
await this.o.persist(this.store);
|
|
85
|
+
return { redirect: this.o.finish(id) };
|
|
86
|
+
}
|
|
87
|
+
// What the web route shows: who is asking. Null if the request is gone.
|
|
88
|
+
pending(id) {
|
|
89
|
+
const p = own(this.store.pending, id);
|
|
90
|
+
if (!p || p.exp < this.now())
|
|
91
|
+
return null;
|
|
92
|
+
const client = own(this.store.clients, p.client_id);
|
|
93
|
+
return client ? { ...p, client } : null;
|
|
94
|
+
}
|
|
95
|
+
// The web route's last step: the human allowed this client as this identity.
|
|
96
|
+
async complete(id, identity) {
|
|
97
|
+
const p = this.pending(id);
|
|
98
|
+
if (!p)
|
|
99
|
+
return { error: 'expired' };
|
|
100
|
+
delete this.store.pending[id];
|
|
101
|
+
const code = token();
|
|
102
|
+
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
103
|
+
await this.o.persist(this.store);
|
|
104
|
+
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
105
|
+
}
|
|
106
|
+
// The human refused, or the request died: the client hears that.
|
|
107
|
+
async deny(id) {
|
|
108
|
+
const p = this.pending(id);
|
|
109
|
+
if (!p)
|
|
110
|
+
return { error: 'expired' };
|
|
111
|
+
delete this.store.pending[id];
|
|
112
|
+
await this.o.persist(this.store);
|
|
113
|
+
return { redirect: withQuery(p.redirect_uri, { error: 'access_denied', state: p.state }) };
|
|
114
|
+
}
|
|
115
|
+
async token(body) {
|
|
116
|
+
const grant = body.get('grant_type');
|
|
117
|
+
if (grant === 'authorization_code') {
|
|
118
|
+
const c = own(this.store.codes, body.get('code') ?? '');
|
|
119
|
+
if (!c || c.exp < this.now())
|
|
120
|
+
return { error: 'invalid_grant' };
|
|
121
|
+
delete this.store.codes[body.get('code') ?? '']; // a code is spent the first time it is offered, right or wrong
|
|
122
|
+
const verifier = body.get('code_verifier') ?? '';
|
|
123
|
+
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) {
|
|
124
|
+
await this.o.persist(this.store);
|
|
125
|
+
return { error: 'invalid_grant' };
|
|
126
|
+
}
|
|
127
|
+
return this.issue(c.identity, c.client_id);
|
|
128
|
+
}
|
|
129
|
+
if (grant === 'refresh_token') {
|
|
130
|
+
const rt = body.get('refresh_token') ?? '';
|
|
131
|
+
const r = own(this.store.refresh, rt);
|
|
132
|
+
if (!r || r.exp < this.now())
|
|
133
|
+
return { error: 'invalid_grant' };
|
|
134
|
+
delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
|
|
135
|
+
return this.issue(r.identity, r.client_id);
|
|
136
|
+
}
|
|
137
|
+
return { error: 'unsupported_grant_type' };
|
|
138
|
+
}
|
|
139
|
+
async issue(identity, client_id) {
|
|
140
|
+
const access_token = token(), refresh_token = token();
|
|
141
|
+
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
142
|
+
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
143
|
+
this.sweep();
|
|
144
|
+
await this.o.persist(this.store);
|
|
145
|
+
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
146
|
+
}
|
|
147
|
+
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
148
|
+
bearer(req) {
|
|
149
|
+
const h = req.headers.authorization ?? '';
|
|
150
|
+
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
151
|
+
const g = own(this.store.access, t);
|
|
152
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
|
|
153
|
+
}
|
|
154
|
+
// Every grant an identity holds, gone: the route's half of revocation.
|
|
155
|
+
// The other half is the user being removing the occupant.
|
|
156
|
+
async revoke(identity) {
|
|
157
|
+
for (const k of ['access', 'refresh'])
|
|
158
|
+
for (const [t, g] of Object.entries(this.store[k]))
|
|
159
|
+
if (g.identity === identity)
|
|
160
|
+
delete this.store[k][t];
|
|
161
|
+
await this.o.persist(this.store);
|
|
162
|
+
}
|
|
163
|
+
sweep() {
|
|
164
|
+
const now = this.now();
|
|
165
|
+
for (const k of ['pending', 'codes', 'access', 'refresh'])
|
|
166
|
+
for (const [t, g] of Object.entries(this.store[k]))
|
|
167
|
+
if (g.exp < now)
|
|
168
|
+
delete this.store[k][t];
|
|
169
|
+
}
|
|
170
|
+
// The HTTP face. `rest` is the path under the route.
|
|
171
|
+
async handle(req, res, rest) {
|
|
172
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
173
|
+
const json = (status, body, headers = {}) => {
|
|
174
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });
|
|
175
|
+
res.end(JSON.stringify(body));
|
|
176
|
+
};
|
|
177
|
+
if (rest === '/.well-known/oauth-authorization-server' && req.method === 'GET')
|
|
178
|
+
return json(200, this.metadata()), true;
|
|
179
|
+
if ((rest === '/.well-known/oauth-protected-resource' || rest === '/.well-known/oauth-protected-resource/mcp') && req.method === 'GET')
|
|
180
|
+
return json(200, this.protectedResource()), true;
|
|
181
|
+
if (rest === '/register' && req.method === 'POST') {
|
|
182
|
+
const out = await this.register(await readJson(req));
|
|
183
|
+
return json('error' in out ? 400 : 201, out), true;
|
|
184
|
+
}
|
|
185
|
+
if (rest === '/authorize' && req.method === 'GET') {
|
|
186
|
+
const out = await this.authorize(url.searchParams);
|
|
187
|
+
if ('error' in out)
|
|
188
|
+
return json(400, out), true;
|
|
189
|
+
res.writeHead(302, { location: out.redirect, 'cache-control': 'no-store' });
|
|
190
|
+
res.end();
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (rest === '/token' && req.method === 'POST') {
|
|
194
|
+
const out = await this.token(await readForm(req));
|
|
195
|
+
return json('error' in out ? 400 : 200, out), true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
// What an unauthenticated MCP request is told: where to go.
|
|
200
|
+
challenge(res) {
|
|
201
|
+
res.writeHead(401, { 'www-authenticate': `Bearer resource_metadata="${this.o.issuer}/.well-known/oauth-protected-resource/mcp"`, 'content-type': 'application/json' });
|
|
202
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function withQuery(base, params) {
|
|
206
|
+
const u = new URL(base);
|
|
207
|
+
for (const [k, v] of Object.entries(params))
|
|
208
|
+
if (v !== null)
|
|
209
|
+
u.searchParams.set(k, v);
|
|
210
|
+
return u.toString();
|
|
211
|
+
}
|
|
212
|
+
export async function readBody(req, limit = 64 * 1024) {
|
|
213
|
+
let s = '';
|
|
214
|
+
for await (const chunk of req) {
|
|
215
|
+
s += chunk;
|
|
216
|
+
if (s.length > limit)
|
|
217
|
+
throw new Error('too large');
|
|
218
|
+
}
|
|
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['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,6 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import type { Serving } from '../beings/side.ts';
|
|
4
|
+
export declare const PILOT_TOOLS: Tool[];
|
|
5
|
+
export type Log = (line: string) => void;
|
|
6
|
+
export declare function pilotSide(dir: string, ward: string, transport: Transport, log?: Log, via?: string): Promise<Serving>;
|