@openlap/openlap 1.0.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/dist/auth.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2
+ import type { OAuthClientMetadata, OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
3
+ export declare class FileOAuthProvider implements OAuthClientProvider {
4
+ authCode: string | null;
5
+ get redirectUrl(): URL;
6
+ get clientMetadata(): OAuthClientMetadata;
7
+ clientInformation(): Promise<OAuthClientInformationFull | undefined>;
8
+ saveClientInformation(info: OAuthClientInformationFull): Promise<void>;
9
+ tokens(): Promise<OAuthTokens | undefined>;
10
+ saveTokens(tokens: OAuthTokens): Promise<void>;
11
+ redirectToAuthorization(authorizationUrl: URL): Promise<void>;
12
+ saveCodeVerifier(codeVerifier: string): Promise<void>;
13
+ codeVerifier(): Promise<string>;
14
+ private waitForCallback;
15
+ }
16
+ export declare function hasToken(): boolean;
17
+ export declare function clearAuth(): void;
package/dist/auth.js ADDED
@@ -0,0 +1,109 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { createServer } from "http";
5
+ const CONFIG_DIR = join(homedir(), ".openlap");
6
+ const AUTH_FILE = join(CONFIG_DIR, "auth.json");
7
+ function load() {
8
+ if (!existsSync(AUTH_FILE))
9
+ return {};
10
+ try {
11
+ return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ function save(data) {
18
+ mkdirSync(CONFIG_DIR, { recursive: true });
19
+ writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2));
20
+ }
21
+ const CALLBACK_PORT = 19284;
22
+ export class FileOAuthProvider {
23
+ // Captured auth code from callback, used by proxy to call finishAuth
24
+ authCode = null;
25
+ get redirectUrl() {
26
+ return new URL(`http://127.0.0.1:${CALLBACK_PORT}/callback`);
27
+ }
28
+ get clientMetadata() {
29
+ return {
30
+ client_name: "openlap",
31
+ redirect_uris: [`http://127.0.0.1:${CALLBACK_PORT}/callback`],
32
+ grant_types: ["authorization_code"],
33
+ response_types: ["code"],
34
+ };
35
+ }
36
+ async clientInformation() {
37
+ return load().client;
38
+ }
39
+ async saveClientInformation(info) {
40
+ const data = load();
41
+ data.client = info;
42
+ save(data);
43
+ }
44
+ async tokens() {
45
+ return load().tokens;
46
+ }
47
+ async saveTokens(tokens) {
48
+ const data = load();
49
+ data.tokens = tokens;
50
+ save(data);
51
+ }
52
+ async redirectToAuthorization(authorizationUrl) {
53
+ process.stderr.write(`\nOpening browser for authentication...\n`);
54
+ process.stderr.write(`If the browser doesn't open, visit:\n${authorizationUrl.toString()}\n\n`);
55
+ const { default: openUrl } = await import("open");
56
+ await openUrl(authorizationUrl.toString());
57
+ // Wait for callback and capture auth code
58
+ this.authCode = await this.waitForCallback();
59
+ }
60
+ async saveCodeVerifier(codeVerifier) {
61
+ const data = load();
62
+ data.codeVerifier = codeVerifier;
63
+ save(data);
64
+ }
65
+ async codeVerifier() {
66
+ return load().codeVerifier ?? "";
67
+ }
68
+ waitForCallback() {
69
+ return new Promise((resolve, reject) => {
70
+ const timeout = setTimeout(() => {
71
+ server.close();
72
+ reject(new Error("OAuth callback timed out after 2 minutes"));
73
+ }, 120_000);
74
+ const server = createServer((req, res) => {
75
+ if (!req.url?.startsWith("/callback")) {
76
+ res.writeHead(404);
77
+ res.end();
78
+ return;
79
+ }
80
+ const url = new URL(req.url, `http://127.0.0.1:${CALLBACK_PORT}`);
81
+ const code = url.searchParams.get("code");
82
+ if (!code) {
83
+ const error = url.searchParams.get("error") ?? "no code received";
84
+ res.writeHead(400, { "Content-Type": "text/html" });
85
+ res.end(`<html><body><h2>Authentication failed: ${error}</h2></body></html>`);
86
+ clearTimeout(timeout);
87
+ server.close();
88
+ reject(new Error(`OAuth failed: ${error}`));
89
+ return;
90
+ }
91
+ res.writeHead(200, { "Content-Type": "text/html" });
92
+ res.end("<html><body><h2>Authenticated. You can close this tab.</h2></body></html>");
93
+ clearTimeout(timeout);
94
+ server.close();
95
+ resolve(code);
96
+ });
97
+ server.listen(CALLBACK_PORT, "127.0.0.1");
98
+ });
99
+ }
100
+ }
101
+ export function hasToken() {
102
+ const data = load();
103
+ return !!data.tokens?.access_token;
104
+ }
105
+ export function clearAuth() {
106
+ if (existsSync(AUTH_FILE)) {
107
+ writeFileSync(AUTH_FILE, "{}");
108
+ }
109
+ }
package/dist/feed.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ interface TrackUpdate {
2
+ id: string;
3
+ project_name: string;
4
+ body: string;
5
+ health: string;
6
+ created_at: string;
7
+ }
8
+ type UpdateCallback = (tag: string, update: TrackUpdate) => void;
9
+ export declare class FeedManager {
10
+ private baseUrl;
11
+ private streams;
12
+ private seenIds;
13
+ private callback;
14
+ constructor(baseUrl: string, callback: UpdateCallback);
15
+ /**
16
+ * Subscribe to a tag's feed if not already subscribed.
17
+ */
18
+ subscribe(tag: string): void;
19
+ private connect;
20
+ private readStream;
21
+ stop(): void;
22
+ }
23
+ export {};
package/dist/feed.js ADDED
@@ -0,0 +1,110 @@
1
+ // Experimental: dynamic SSE feed subscription.
2
+ // Auto-subscribes when agent uses get_track or post_update with a tag.
3
+ export class FeedManager {
4
+ baseUrl;
5
+ streams = new Map();
6
+ seenIds = new Set();
7
+ callback;
8
+ constructor(baseUrl, callback) {
9
+ this.baseUrl = baseUrl;
10
+ this.callback = callback;
11
+ }
12
+ /**
13
+ * Subscribe to a tag's feed if not already subscribed.
14
+ */
15
+ subscribe(tag) {
16
+ if (this.streams.has(tag))
17
+ return;
18
+ process.stderr.write(`[openlap] subscribed to feed: ${tag} (experimental)\n`);
19
+ const controller = new AbortController();
20
+ this.streams.set(tag, controller);
21
+ this.connect(tag, controller);
22
+ }
23
+ connect(tag, controller) {
24
+ const url = `${this.baseUrl}/feed/${tag}/sse`;
25
+ fetch(url, { signal: controller.signal })
26
+ .then((res) => {
27
+ if (!res.ok || !res.body) {
28
+ throw new Error(`SSE connect failed: ${res.status}`);
29
+ }
30
+ return this.readStream(tag, res.body, controller);
31
+ })
32
+ .catch((err) => {
33
+ if (err.name === "AbortError")
34
+ return;
35
+ // Reconnect after 5s
36
+ if (this.streams.has(tag)) {
37
+ setTimeout(() => this.connect(tag, controller), 5000);
38
+ }
39
+ });
40
+ }
41
+ async readStream(tag, body, controller) {
42
+ const reader = body.getReader();
43
+ const decoder = new TextDecoder();
44
+ let buffer = "";
45
+ let eventType = "";
46
+ let eventData = "";
47
+ let eventId = "";
48
+ try {
49
+ while (true) {
50
+ const { done, value } = await reader.read();
51
+ if (done)
52
+ break;
53
+ buffer += decoder.decode(value, { stream: true });
54
+ const lines = buffer.split("\n");
55
+ buffer = lines.pop() ?? "";
56
+ for (const line of lines) {
57
+ if (line.startsWith("event: ")) {
58
+ eventType = line.slice(7);
59
+ }
60
+ else if (line.startsWith("data: ")) {
61
+ eventData = line.slice(6);
62
+ }
63
+ else if (line.startsWith("id: ")) {
64
+ eventId = line.slice(4);
65
+ }
66
+ else if (line === "") {
67
+ if (eventType === "update" && eventData && eventId) {
68
+ if (!this.seenIds.has(eventId)) {
69
+ this.seenIds.add(eventId);
70
+ // Cap at 500
71
+ if (this.seenIds.size > 500) {
72
+ const iter = this.seenIds.values();
73
+ for (let i = 0; i < 100; i++)
74
+ iter.next();
75
+ const toKeep = new Set();
76
+ for (const v of iter)
77
+ toKeep.add(v);
78
+ this.seenIds = toKeep;
79
+ }
80
+ try {
81
+ const update = JSON.parse(eventData);
82
+ this.callback(tag, update);
83
+ }
84
+ catch {
85
+ // skip malformed
86
+ }
87
+ }
88
+ }
89
+ eventType = "";
90
+ eventData = "";
91
+ eventId = "";
92
+ }
93
+ }
94
+ }
95
+ }
96
+ catch {
97
+ // stream ended
98
+ }
99
+ // Reconnect if still subscribed
100
+ if (this.streams.has(tag) && !controller.signal.aborted) {
101
+ setTimeout(() => this.connect(tag, controller), 5000);
102
+ }
103
+ }
104
+ stop() {
105
+ for (const [, controller] of this.streams) {
106
+ controller.abort();
107
+ }
108
+ this.streams.clear();
109
+ }
110
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Detect project (owner/repo) from git remote origin.
3
+ */
4
+ export declare function detectProject(): string | null;
5
+ /**
6
+ * Auto-save: git add, commit, push before post_update.
7
+ * Returns the commit message used, or null if nothing to commit.
8
+ */
9
+ export declare function autoSave(body: string): string | null;
package/dist/git.js ADDED
@@ -0,0 +1,56 @@
1
+ import { execSync } from "child_process";
2
+ /**
3
+ * Detect project (owner/repo) from git remote origin.
4
+ */
5
+ export function detectProject() {
6
+ try {
7
+ const remote = execSync("git remote get-url origin", {
8
+ encoding: "utf-8",
9
+ stdio: ["pipe", "pipe", "pipe"],
10
+ }).trim();
11
+ // Handle SSH: git@github.com:owner/repo.git
12
+ const sshMatch = remote.match(/git@[^:]+:(.+?)(?:\.git)?$/);
13
+ if (sshMatch)
14
+ return sshMatch[1];
15
+ // Handle HTTPS: https://github.com/owner/repo.git
16
+ const httpsMatch = remote.match(/https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
17
+ if (httpsMatch)
18
+ return httpsMatch[1];
19
+ return null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /**
26
+ * Auto-save: git add, commit, push before post_update.
27
+ * Returns the commit message used, or null if nothing to commit.
28
+ */
29
+ export function autoSave(body) {
30
+ try {
31
+ // Stage all changes
32
+ execSync("git add -A", { stdio: "pipe" });
33
+ // Check if there are staged changes
34
+ try {
35
+ execSync("git diff --cached --quiet", { stdio: "pipe" });
36
+ return null; // nothing staged
37
+ }
38
+ catch {
39
+ // diff --cached --quiet exits 1 when there are changes
40
+ }
41
+ // Commit with first line of update body as message
42
+ const msg = body.split("\n")[0].slice(0, 72);
43
+ execSync(`git commit -m ${JSON.stringify(msg)}`, { stdio: "pipe" });
44
+ // Push (best effort)
45
+ try {
46
+ execSync("git push", { stdio: "pipe" });
47
+ }
48
+ catch {
49
+ // push failure shouldn't block the update
50
+ }
51
+ return msg;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "child_process";
3
+ const command = process.argv[2];
4
+ // -- Setup subcommand: adds MCP server to Claude Code -----------------------
5
+ if (command === "setup") {
6
+ console.log("Setting up openlap...");
7
+ console.log("");
8
+ try {
9
+ // Add new "openlap" entry first (safe -- if this fails, old config still works)
10
+ let added = false;
11
+ try {
12
+ execSync(`claude mcp add -s user openlap -- npx -y @openlap/openlap`, { stdio: "pipe" });
13
+ added = true;
14
+ console.log("Added openlap MCP server.");
15
+ }
16
+ catch (err) {
17
+ const msg = err instanceof Error ? err.stderr?.toString() ?? err.message : String(err);
18
+ if (msg.includes("already exists")) {
19
+ console.log("openlap MCP server already installed.");
20
+ added = true;
21
+ }
22
+ else {
23
+ throw err;
24
+ }
25
+ }
26
+ // Only remove old entries after new one is confirmed
27
+ if (added) {
28
+ // Remove old "anylap" entry if present (migration from @openlap/anylap)
29
+ try {
30
+ execSync("claude mcp remove anylap", { stdio: "pipe" });
31
+ console.log("Migrated: removed old 'anylap' entry.");
32
+ }
33
+ catch {
34
+ // Not present, fine
35
+ }
36
+ // Remove old "lap" HTTP entry if present (migration from direct HTTP setup)
37
+ try {
38
+ execSync("claude mcp remove lap", { stdio: "pipe" });
39
+ console.log("Migrated: removed old 'lap' HTTP entry.");
40
+ }
41
+ catch {
42
+ // Not present, fine
43
+ }
44
+ }
45
+ console.log("");
46
+ console.log("Done. Open Claude Code -- first message triggers GitHub login.");
47
+ }
48
+ catch {
49
+ console.error("Failed to add MCP server. Is Claude Code installed?");
50
+ process.exit(1);
51
+ }
52
+ process.exit(0);
53
+ }
54
+ // -- Login subcommand: force re-authentication ------------------------------
55
+ if (command === "login") {
56
+ const { clearAuth } = await import("./auth.js");
57
+ clearAuth();
58
+ console.log("Cleared stored credentials. Next Claude Code session will re-authenticate.");
59
+ process.exit(0);
60
+ }
61
+ // -- Help -------------------------------------------------------------------
62
+ if (command === "help" || command === "--help" || command === "-h") {
63
+ console.log("Usage: npx @openlap/openlap <command>");
64
+ console.log("");
65
+ console.log("Commands:");
66
+ console.log(" setup Add openlap MCP server to Claude Code");
67
+ console.log(" login Clear stored credentials and re-authenticate");
68
+ console.log(" help Show this help");
69
+ console.log("");
70
+ console.log("When run without arguments, starts the MCP proxy server (used by Claude Code).");
71
+ process.exit(0);
72
+ }
73
+ // -- Default: run as MCP proxy server (called by Claude Code) ---------------
74
+ if (command && command !== "serve") {
75
+ console.error(`Unknown command: ${command}`);
76
+ console.error("Run 'npx @openlap/openlap help' for usage.");
77
+ process.exit(1);
78
+ }
79
+ const { startProxy } = await import("./proxy.js");
80
+ await startProxy();
@@ -0,0 +1 @@
1
+ export declare function startProxy(): Promise<void>;
package/dist/proxy.js ADDED
@@ -0,0 +1,114 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
+ import { FileOAuthProvider } from "./auth.js";
7
+ import { detectProject, autoSave } from "./git.js";
8
+ import { FeedManager } from "./feed.js";
9
+ const BASE_URL = process.env.ANYLAP_URL ?? "https://openlap.app";
10
+ // Tools that accept a 'project' parameter
11
+ const PROJECT_TOOLS = new Set([
12
+ "list_laps", "get_lap", "create_lap", "save_lap", "update_lap",
13
+ "post_update", "list_updates",
14
+ "create_project", "update_project", "remove_project",
15
+ ]);
16
+ export async function startProxy() {
17
+ const authProvider = new FileOAuthProvider();
18
+ // -- Connect to remote MCP server ----------------------------------------
19
+ const remoteTransport = new StreamableHTTPClientTransport(new URL(`${BASE_URL}/mcp`), { authProvider });
20
+ const remote = new Client({ name: "openlap-proxy", version: "1.0.0" }, { capabilities: {} });
21
+ // Try connecting -- if auth is needed, the provider opens the browser
22
+ try {
23
+ await remote.connect(remoteTransport);
24
+ }
25
+ catch (err) {
26
+ // After redirectToAuthorization, the SDK throws UnauthorizedError.
27
+ // We have the auth code from the callback -- finish the flow.
28
+ if (authProvider.authCode) {
29
+ await remoteTransport.finishAuth(authProvider.authCode);
30
+ // Retry connection
31
+ await remote.connect(remoteTransport);
32
+ }
33
+ else {
34
+ const msg = err instanceof Error ? err.message : String(err);
35
+ process.stderr.write(`[openlap] auth error: ${msg}\n`);
36
+ process.exit(1);
37
+ }
38
+ }
39
+ // -- Auto-detect project from git remote ----------------------------------
40
+ const detectedProject = detectProject();
41
+ if (detectedProject) {
42
+ process.stderr.write(`[openlap] project: ${detectedProject}\n`);
43
+ }
44
+ // -- Local MCP server (stdio) ---------------------------------------------
45
+ const server = new Server({ name: "openlap", version: "1.0.0" }, {
46
+ capabilities: {
47
+ tools: {},
48
+ experimental: { "claude/channel": {} },
49
+ },
50
+ });
51
+ // -- Experimental: dynamic feed subscription ------------------------------
52
+ const feeds = new FeedManager(BASE_URL, (tag, update) => {
53
+ const who = update.project_name || "unknown";
54
+ const health = update.health && update.health !== "on_track" ? ` [${update.health}]` : "";
55
+ const content = `[${who}]${health} ${update.body}`;
56
+ process.stderr.write(`[openlap] feed ${tag}: ${content}\n`);
57
+ server.notification({
58
+ method: "notifications/claude/channel",
59
+ params: {
60
+ content,
61
+ meta: {
62
+ update_id: update.id,
63
+ tag,
64
+ created_at: update.created_at,
65
+ health: update.health ?? "on_track",
66
+ },
67
+ },
68
+ }).catch((err) => {
69
+ if (!String(err).includes("Not connected")) {
70
+ process.stderr.write(`[openlap] notification error: ${err}\n`);
71
+ }
72
+ });
73
+ });
74
+ // Forward tool listing from remote
75
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
76
+ const result = await remote.listTools();
77
+ return { tools: result.tools };
78
+ });
79
+ // Forward tool calls with local enhancements
80
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
81
+ const { name } = req.params;
82
+ const args = { ...(req.params.arguments ?? {}) };
83
+ // Auto-inject project if not provided and detectable
84
+ if (detectedProject && PROJECT_TOOLS.has(name) && !args.project) {
85
+ args.project = detectedProject;
86
+ }
87
+ // Auto-save before post_update
88
+ if (name === "post_update" && typeof args.body === "string") {
89
+ const saved = autoSave(args.body);
90
+ if (saved) {
91
+ process.stderr.write(`[openlap] auto-saved: ${saved}\n`);
92
+ }
93
+ }
94
+ // Forward to remote
95
+ const result = await remote.callTool({ name, arguments: args });
96
+ // Experimental: auto-subscribe to feeds from tool usage
97
+ if (typeof args.tag === "string" && args.tag) {
98
+ feeds.subscribe(args.tag);
99
+ }
100
+ if (name === "get_track" && typeof args.name === "string" && args.name) {
101
+ feeds.subscribe(args.name);
102
+ }
103
+ if (name === "create_track" && typeof args.name === "string" && args.name) {
104
+ feeds.subscribe(args.name);
105
+ }
106
+ return {
107
+ content: result.content,
108
+ isError: result.isError,
109
+ };
110
+ });
111
+ // -- Start stdio transport ------------------------------------------------
112
+ const transport = new StdioServerTransport();
113
+ await server.connect(transport);
114
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@openlap/openlap",
3
+ "version": "1.0.0",
4
+ "description": "Local MCP proxy for openlap.app -- auto-save, live feeds, project detection, one install",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "openlap": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build",
16
+ "dev": "npx tsx src/index.ts"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.12.0",
20
+ "open": "^10.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^25.5.0",
24
+ "typescript": "^5.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/wildreason/openlap"
33
+ },
34
+ "keywords": [
35
+ "claude-code",
36
+ "mcp",
37
+ "openlap",
38
+ "agent-coordination"
39
+ ]
40
+ }