@miadi/connector 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Read access to the Miadi Review service. Published reviews are public
3
+ * (`/review/<id>/json`); search by keyword or video id needs the service
4
+ * token, read the same way the miadi-review skill reads it: the process
5
+ * environment first, then the one literal key in ${MIADI_REVIEW_ENV_FILE:-~/.env}.
6
+ *
7
+ * This client never creates, versions, deletes or marks a review: those are
8
+ * requests a person approves (see the review_* request tools).
9
+ */
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+ const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
15
+ export function reviewToken(env = process.env) {
16
+ if (env.MIADI_REVIEW_TOKEN)
17
+ return env.MIADI_REVIEW_TOKEN;
18
+ const file = env.MIADI_REVIEW_ENV_FILE || join(homedir(), ".env");
19
+ if (!existsSync(file))
20
+ return undefined;
21
+ for (const line of readFileSync(file, "utf8").split("\n")) {
22
+ const m = line.match(/^\s*(?:export\s+)?MIADI_REVIEW_TOKEN\s*=\s*(.*)\s*$/);
23
+ if (m)
24
+ return m[1].replace(/^['"]|['"]$/g, "") || undefined;
25
+ }
26
+ return undefined;
27
+ }
28
+ export function videoIdOf(input) {
29
+ const s = input.trim();
30
+ if (VIDEO_ID.test(s))
31
+ return s;
32
+ try {
33
+ const u = new URL(s);
34
+ if (u.hostname === "youtu.be")
35
+ return u.pathname.slice(1, 12) || null;
36
+ if (u.hostname.endsWith("youtube.com"))
37
+ return u.searchParams.get("v") ?? u.pathname.split("/").pop() ?? null;
38
+ }
39
+ catch {
40
+ /* not a URL */
41
+ }
42
+ return null;
43
+ }
44
+ export function reviewIdOf(input) {
45
+ const s = input.trim().replace(/^miadi-review:/, "").replace(/^review:/, "");
46
+ const fromUrl = s.match(/\/review\/([0-9a-f-]{36})/i);
47
+ const id = fromUrl ? fromUrl[1] : s;
48
+ return UUID.test(id) ? id.toLowerCase() : null;
49
+ }
50
+ export class ReviewClient {
51
+ baseUrl;
52
+ token;
53
+ fetchImpl;
54
+ constructor(baseUrl, token, fetchImpl = fetch) {
55
+ this.baseUrl = baseUrl;
56
+ this.token = token;
57
+ this.fetchImpl = fetchImpl;
58
+ }
59
+ get canSearch() {
60
+ return Boolean(this.token);
61
+ }
62
+ /** `/review/<id>/json`, optionally pinned to a version. Null on 404. */
63
+ async get(reviewId, version) {
64
+ const q = version ? `?v=${version}` : "";
65
+ const res = await this.fetchImpl(`${this.baseUrl}/review/${reviewId}/json${q}`, { headers: { accept: "application/json" } });
66
+ if (res.status === 404)
67
+ return null;
68
+ if (!res.ok)
69
+ throw new Error(`review service answered ${res.status} for ${reviewId}`);
70
+ return res.json();
71
+ }
72
+ async search(q, limit = 10) {
73
+ if (!this.token)
74
+ throw new Error("MIADI_REVIEW_TOKEN is not configured on the connector host");
75
+ const res = await this.fetchImpl(`${this.baseUrl}/api/reviews/search?${new URLSearchParams({ q, limit: String(limit) })}`, {
76
+ headers: { accept: "application/json", authorization: `Bearer ${this.token}` },
77
+ });
78
+ if (!res.ok)
79
+ throw new Error(`review search answered ${res.status}`);
80
+ const body = await res.json();
81
+ return body.reviews ?? body.results ?? [];
82
+ }
83
+ summary(r) {
84
+ return {
85
+ review_id: r.id,
86
+ ref: `miadi-review:${r.id}`,
87
+ url: `${this.baseUrl}/review/${r.id}`,
88
+ title: r.title ?? null,
89
+ video_id: r.video_id ?? null,
90
+ video_url: r.video_url ?? null,
91
+ latest_version: r.latest_version ?? null,
92
+ diver_ready: r.diver_ready ?? null,
93
+ created_at: r.created_at,
94
+ updated_at: r.updated_at,
95
+ };
96
+ }
97
+ }
@@ -0,0 +1,18 @@
1
+ import { MiadiOAuthProvider } from "./oauth.js";
2
+ import type { MiadiPerson } from "./people.js";
3
+ import { Ledger } from "./store.js";
4
+ import type { ConnectorRecord } from "./records.js";
5
+ import type { ConnectorConfig } from "./config.js";
6
+ export interface ConnectorDeps {
7
+ identify: (token: string) => Promise<MiadiPerson | null>;
8
+ fetchImpl?: typeof fetch;
9
+ /** The factory directory holding queue/ (default ~/.miadi/factory). */
10
+ factoryDir?: string;
11
+ tmuxSessions?: () => string[];
12
+ }
13
+ export declare function createConnectorApp(config: ConnectorConfig, deps: ConnectorDeps): {
14
+ app: import("express-serve-static-core").Express;
15
+ provider: MiadiOAuthProvider;
16
+ ledger: Ledger<ConnectorRecord>;
17
+ };
18
+ export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, review operations, Deep Diver, review-to-episode links, session completion, calendar events) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person approves it in Miadi.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
package/dist/server.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The connector's HTTP surface:
3
+ *
4
+ * /.well-known/oauth-* OAuth discovery (MCP authorization spec)
5
+ * /authorize /token /register /revoke, /authorize/confirm sign-in
6
+ * /mcp the MCP endpoint (streamable HTTP, stateless)
7
+ * /health liveness, no data
8
+ *
9
+ * Every MCP request carries the signed-in person, and each tool call runs
10
+ * as that person (see tools.ts).
11
+ */
12
+ import express from "express";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
+ import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
16
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
17
+ import { MiadiOAuthProvider, SCOPES, emptyOAuthState } from "./oauth.js";
18
+ import { registerApprovalRoutes } from "./approve.js";
19
+ import { JsonFile, Ledger, ensureStateDir, statePaths } from "./store.js";
20
+ import { registerTools } from "./tools.js";
21
+ import { MiadiClient } from "./miadi.js";
22
+ import { ReviewClient } from "./reviews.js";
23
+ import { VERSION } from "./version.js";
24
+ export function createConnectorApp(config, deps) {
25
+ ensureStateDir(config.stateDir);
26
+ const paths = statePaths(config.stateDir);
27
+ const oauthState = new JsonFile(paths.oauth, emptyOAuthState);
28
+ const ledger = new Ledger(paths.ledger);
29
+ // The issuer string exactly as the metadata states it (URL.href, so with its trailing slash).
30
+ const issuer = new URL(config.publicUrl).href;
31
+ const provider = new MiadiOAuthProvider(oauthState, deps.identify, issuer);
32
+ const miadi = new MiadiClient(config.miadiUrl, deps.fetchImpl);
33
+ const reviews = new ReviewClient(config.reviewUrl, config.reviewToken, deps.fetchImpl);
34
+ const publicUrl = new URL(config.publicUrl);
35
+ const mcpUrl = new URL("/mcp", publicUrl);
36
+ const app = express();
37
+ app.disable("x-powered-by");
38
+ // cloudflared reaches the connector over loopback; only that hop is trusted for the client address.
39
+ app.set("trust proxy", "loopback");
40
+ app.get("/health", (_req, res) => {
41
+ res.json({ ok: true, service: "@miadi/connector", version: VERSION });
42
+ });
43
+ // The SDK's metadata, plus RFC 9207: authorization responses carry `iss`.
44
+ // Served before the SDK router so this document is the one clients read.
45
+ const asMetadata = {
46
+ ...createOAuthMetadata({
47
+ provider,
48
+ issuerUrl: publicUrl,
49
+ baseUrl: publicUrl,
50
+ scopesSupported: SCOPES,
51
+ serviceDocumentationUrl: new URL("https://github.com/jgwill/Miadi/tree/main/packages/connector"),
52
+ }),
53
+ authorization_response_iss_parameter_supported: true,
54
+ };
55
+ app.get("/.well-known/oauth-authorization-server", (_req, res) => {
56
+ res.set("cache-control", "no-store").json(asMetadata);
57
+ });
58
+ app.use(mcpAuthRouter({
59
+ provider,
60
+ issuerUrl: publicUrl,
61
+ baseUrl: publicUrl,
62
+ resourceServerUrl: mcpUrl,
63
+ scopesSupported: SCOPES,
64
+ resourceName: "Miadi",
65
+ serviceDocumentationUrl: new URL("https://github.com/jgwill/Miadi/tree/main/packages/connector"),
66
+ }));
67
+ app.post("/authorize/confirm", express.urlencoded({ extended: false, limit: "16kb" }), (req, res, next) => {
68
+ provider.confirm(req, res).catch(next);
69
+ });
70
+ registerApprovalRoutes(app, {
71
+ config,
72
+ ledger,
73
+ miadi,
74
+ identify: deps.identify,
75
+ fetchImpl: deps.fetchImpl,
76
+ factoryDir: deps.factoryDir,
77
+ tmuxSessions: deps.tmuxSessions,
78
+ });
79
+ const auth = requireBearerAuth({
80
+ verifier: provider,
81
+ requiredScopes: ["miadi:read"],
82
+ resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
83
+ });
84
+ const handle = async (req, res) => {
85
+ const ctx = { config, miadi, reviews, ledger, oauthState, factoryDir: deps.factoryDir };
86
+ const server = new McpServer({ name: "miadi", version: VERSION }, { instructions: INSTRUCTIONS });
87
+ registerTools(server, ctx);
88
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
89
+ res.on("close", () => {
90
+ transport.close().catch(() => { });
91
+ server.close().catch(() => { });
92
+ });
93
+ try {
94
+ await server.connect(transport);
95
+ await transport.handleRequest(req, res, req.body);
96
+ }
97
+ catch (error) {
98
+ if (!res.headersSent) {
99
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null });
100
+ }
101
+ console.error("[connector] /mcp", error);
102
+ }
103
+ };
104
+ app.post("/mcp", auth, express.json({ limit: "4mb" }), handle);
105
+ app.get("/mcp", auth, (_req, res) => {
106
+ res.status(405).set("Allow", "POST").json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed" }, id: null });
107
+ });
108
+ app.delete("/mcp", auth, (_req, res) => {
109
+ res.status(405).set("Allow", "POST").json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed" }, id: null });
110
+ });
111
+ return { app, provider, ledger };
112
+ }
113
+ export const INSTRUCTIONS = `Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).
114
+
115
+ Start with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.
116
+
117
+ Rules every tool keeps:
118
+ - Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.
119
+ - Requests (runs, review operations, Deep Diver, review-to-episode links, session completion, calendar events) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person approves it in Miadi.
120
+ - An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.
121
+ - Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.`;
@@ -0,0 +1,23 @@
1
+ export declare function digest(secret: string): string;
2
+ export declare function newSecret(bytes?: number): string;
3
+ export declare function ensureStateDir(dir: string): void;
4
+ export declare class JsonFile<T> {
5
+ private readonly path;
6
+ private readonly empty;
7
+ constructor(path: string, empty: () => T);
8
+ read(): T;
9
+ write(value: T): void;
10
+ update(fn: (value: T) => void): T;
11
+ }
12
+ export declare class Ledger<R extends {
13
+ id: string;
14
+ }> {
15
+ private readonly path;
16
+ constructor(path: string);
17
+ append(record: R): R;
18
+ all(): R[];
19
+ }
20
+ export declare function statePaths(dir: string): {
21
+ oauth: string;
22
+ ledger: string;
23
+ };
package/dist/store.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The connector's own state, kept in one directory (default
3
+ * /srv/miadi/connector, which the daily /srv/miadi backup carries).
4
+ *
5
+ * - oauth.json registered OAuth clients, pending sign-ins, codes and tokens.
6
+ * Tokens are stored as sha256 digests, never as issued. The
7
+ * person's Miadi token is kept so the connector can act as
8
+ * them; the directory is created 0700 for that reason.
9
+ * - ledger.jsonl the records people contribute through the connector
10
+ * (observations, handoffs, proposals). Append-only: a record
11
+ * is never edited, a later record supersedes it.
12
+ */
13
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ import { join } from "node:path";
16
+ import { readJsonSafe, writeJsonAtomic } from "@miadi/node-service-kit/json";
17
+ export function digest(secret) {
18
+ return createHash("sha256").update(secret).digest("hex");
19
+ }
20
+ export function newSecret(bytes = 32) {
21
+ return randomBytes(bytes).toString("base64url");
22
+ }
23
+ export function ensureStateDir(dir) {
24
+ if (!existsSync(dir))
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ try {
27
+ chmodSync(dir, 0o700);
28
+ }
29
+ catch {
30
+ /* a directory owned by another user keeps its mode; reads still work */
31
+ }
32
+ }
33
+ export class JsonFile {
34
+ path;
35
+ empty;
36
+ constructor(path, empty) {
37
+ this.path = path;
38
+ this.empty = empty;
39
+ }
40
+ read() {
41
+ const r = readJsonSafe(this.path);
42
+ if (r.ok)
43
+ return r.data;
44
+ if (r.missing)
45
+ return this.empty();
46
+ throw new Error(`${this.path} could not be read (${r.corrupt ? "corrupt" : r.code})`);
47
+ }
48
+ write(value) {
49
+ writeJsonAtomic(this.path, value);
50
+ }
51
+ update(fn) {
52
+ const value = this.read();
53
+ fn(value);
54
+ this.write(value);
55
+ return value;
56
+ }
57
+ }
58
+ export class Ledger {
59
+ path;
60
+ constructor(path) {
61
+ this.path = path;
62
+ }
63
+ append(record) {
64
+ appendFileSync(this.path, JSON.stringify(record) + "\n", { mode: 0o600 });
65
+ return record;
66
+ }
67
+ all() {
68
+ if (!existsSync(this.path))
69
+ return [];
70
+ const out = [];
71
+ for (const line of readFileSync(this.path, "utf8").split("\n")) {
72
+ if (!line.trim())
73
+ continue;
74
+ try {
75
+ out.push(JSON.parse(line));
76
+ }
77
+ catch {
78
+ /* a torn last line from a crash is skipped, not fatal */
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ }
84
+ export function statePaths(dir) {
85
+ return {
86
+ oauth: join(dir, "oauth.json"),
87
+ ledger: join(dir, "ledger.jsonl"),
88
+ };
89
+ }
@@ -0,0 +1,19 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ConnectorConfig } from "./config.js";
3
+ import type { MiadiClient } from "./miadi.js";
4
+ import type { ReviewClient } from "./reviews.js";
5
+ import type { JsonFile, Ledger } from "./store.js";
6
+ import type { OAuthState } from "./oauth.js";
7
+ import { type ConnectorRecord } from "./records.js";
8
+ export interface ToolContext {
9
+ config: ConnectorConfig;
10
+ miadi: MiadiClient;
11
+ reviews: ReviewClient;
12
+ ledger: Ledger<ConnectorRecord>;
13
+ oauthState: JsonFile<OAuthState>;
14
+ /** The factory directory holding queue/ (default ~/.miadi/factory). */
15
+ factoryDir?: string;
16
+ }
17
+ export declare function registerTools(server: McpServer, ctx: ToolContext): void;
18
+ /** What get_capabilities reports for each tool. */
19
+ export declare const TOOL_STATUS: Record<string, string>;