Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to mdio are documented here. The format follows
releases yet, so entries are grouped by milestone date. Semantic version
headers start with the first tagged release.

## Unreleased

### Added
- **Per-project MCP config** — `GET /api/projects/:p/mcp-config?username=`
returns everything needed to wire an agent into a project (binary install
one-liner, `mdio mcp install … --project` command, and the ready-to-paste
`.mcp.json` entry), rendered with the caller-visible server URL
(reverse-proxy aware). The web UI's 🤖 button in the project bar shows it
with copy buttons, suggesting `<you>/claude` as the agent identity.

## 2026-07-15 — Agent collaboration suite (PR #9)

### Added
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ Tests are self-contained: each spawns its own server on an ephemeral port with a

## MCP config for an agent

`GET /api/projects/<p>/mcp-config?username=<owner/agent>` returns all of the below
ready-made (install one-liner, `mcp install` command, `.mcp.json` entry) with the
caller-visible server URL; the web UI's 🤖 button in the project bar shows it with
copy buttons.

Easiest — install the binary from a running server, then wire the project:

```sh
Expand Down
15 changes: 15 additions & 0 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,18 @@ export function restoreSnapshot(
author,
});
}

/** Ready-to-paste MCP wiring for a project, rendered with the server's public origin. */
export interface McpConfig {
project: string;
server: string;
username: string;
install: string;
configure: string;
mcpServers: Record<string, unknown>;
}

export function getMcpConfig(project: string, username: string): Promise<McpConfig> {
const url = `/api/projects/${encodeURIComponent(project)}/mcp-config?username=${encodeURIComponent(username)}`;
return api('GET', url);
}
10 changes: 10 additions & 0 deletions src/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ <h1>mdio</h1>
<button id="project-new" type="button" title="New project">+</button>
<button id="project-rename" type="button" title="Rename this project">✎</button>
<button id="project-delete" type="button" title="Delete this project and all its documents">🗑</button>
<button id="project-mcp" type="button" title="MCP config to wire an agent into this project">🤖</button>
</div>
<input id="doc-search" type="search" placeholder="Search this project…" autocomplete="off" />
<ul id="doc-list"></ul>
Expand Down Expand Up @@ -83,6 +84,15 @@ <h1>mdio</h1>
<div id="versions-list"></div>
</div>
</div>
<div id="mcp-config" hidden>
<div id="mcp-config-panel">
<header id="mcp-config-toolbar">
<span id="mcp-config-title"></span>
<button id="mcp-config-close" type="button" title="Close">✕</button>
</header>
<div id="mcp-config-body"></div>
</div>
</div>
<div id="login" hidden>
<form id="login-form">
<h2>Who are you?</h2>
Expand Down
9 changes: 9 additions & 0 deletions src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { commentHighlightExtension, focusThread, setShowResolved, wireComments }
import { setPreviewEnabled, wirePreview } from './preview';
import { closeHistory, openHistory } from './history';
import { closeVersions, openVersions } from './versions';
import { openMcpConfig } from './mcp-config';
import { suggestionHighlightExtension, wireSuggestions } from './suggestions';
import { onUrlChange, readUrlState, writeUrlState, type UrlState } from './url-state';
import * as api from './api';
Expand Down Expand Up @@ -420,6 +421,14 @@ function wireCrud() {
}
});

document.querySelector('#project-mcp')!.addEventListener('click', () => {
if (!currentProject) {
return;
}
// Humans can't contain "/", so <me>/claude is a valid owner/agent suggestion.
void openMcpConfig(currentProject, `${user.name}/claude`);
});

document.querySelector('#project-new')!.addEventListener('click', () => {
const name = prompt('New project name')?.trim();
if (!name) {
Expand Down
75 changes: 75 additions & 0 deletions src/client/mcp-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { getMcpConfig } from './api';

/**
* MCP-config overlay: everything a human needs to wire an agent into the
* current project — the binary install one-liner, the `mdio mcp install`
* command, and the raw `.mcp.json` entry — each with a copy button. The
* content comes from GET /api/projects/:p/mcp-config, so the server URL is
* the caller-visible origin (reverse-proxy aware).
*/

const overlay = document.querySelector('#mcp-config')! as HTMLElement;
const titleEl = document.querySelector('#mcp-config-title')!;
const bodyEl = document.querySelector('#mcp-config-body')!;
const closeButton = document.querySelector('#mcp-config-close')! as HTMLButtonElement;

function block(label: string, text: string): HTMLElement {
const section = document.createElement('section');
section.className = 'mcp-block';

const head = document.createElement('div');
head.className = 'mcp-block-head';
const caption = document.createElement('span');
caption.textContent = label;
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'mcp-copy';
copy.textContent = 'copy';
copy.addEventListener('click', () => {
void navigator.clipboard.writeText(text).then(() => {
copy.textContent = '✓ copied';
setTimeout(() => {
copy.textContent = 'copy';
}, 1500);
});
});
head.append(caption, copy);

const pre = document.createElement('pre');
pre.textContent = text;

section.append(head, pre);
return section;
}

closeButton.addEventListener('click', closeMcpConfig);

export function closeMcpConfig(): void {
overlay.hidden = true;
bodyEl.innerHTML = '';
}

export async function openMcpConfig(project: string, username: string): Promise<void> {
titleEl.textContent = `${project} — MCP config`;
bodyEl.innerHTML = '';
overlay.hidden = false;
try {
const config = await getMcpConfig(project, username);
bodyEl.append(
block('1. Install the mdio binary (once per machine)', config.install),
block('2. Wire the agent’s workspace (writes .mcp.json + the skill)', config.configure),
block('Or paste the entry into .mcp.json yourself', JSON.stringify({ mcpServers: config.mcpServers }, null, 2)),
);
const hint = document.createElement('p');
hint.className = 'mcp-hint';
hint.textContent =
`The agent joins as "${config.username}" and sees only the "${config.project}" project — ` +
'edit the username to wire a different peer.';
bodyEl.append(hint);
} catch (error) {
const failed = document.createElement('p');
failed.className = 'mcp-hint';
failed.textContent = error instanceof Error ? error.message : String(error);
bodyEl.append(failed);
}
}
104 changes: 104 additions & 0 deletions src/client/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,110 @@ body {
color: #333;
}

#mcp-config {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #fafafaee;
z-index: 10;
}

#mcp-config[hidden] {
display: none;
}

#mcp-config-panel {
display: flex;
flex-direction: column;
width: min(640px, 92vw);
max-height: 80vh;
border: 1px solid #ddd;
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 30px #00000014;
overflow: hidden;
}

#mcp-config-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid #ddd;
}

#mcp-config-title {
font-weight: 600;
font-size: 14px;
}

#mcp-config-close {
border: none;
background: none;
font-size: 13px;
color: #888;
cursor: pointer;
}

#mcp-config-close:hover {
color: #333;
}

#mcp-config-body {
overflow-y: auto;
padding: 12px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}

.mcp-block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: #555;
margin-bottom: 4px;
}

.mcp-copy {
padding: 2px 10px;
border: 1px solid #ccc;
border-radius: 10px;
background: #fff;
color: #666;
cursor: pointer;
font-size: 11px;
white-space: nowrap;
}

.mcp-copy:hover {
background: #eee;
color: #333;
}

.mcp-block pre {
margin: 0;
padding: 8px 10px;
border: 1px solid #eee;
border-radius: 6px;
background: #fafafa;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
}

.mcp-hint {
font-size: 12px;
color: #888;
}

.peer {
font-size: 12px;
padding: 2px 10px;
Expand Down
32 changes: 32 additions & 0 deletions src/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// DELETE /api/projects/:p delete a project (docs included)
// GET /api/projects/:p/mentions ?who&open open threads @mentioning a peer (all docs)
// GET /api/projects/:p/search ?q&limit full-text search across the project
// GET /api/projects/:p/mcp-config ?username ready-to-paste MCP wiring for this project
// GET /api/projects/:p/docs list documents (project-relative)
// POST /api/projects/:p/docs {path} create an empty document
// PATCH /api/projects/:p/docs/*d {project?, path?} rename / move a document
Expand All @@ -21,7 +22,9 @@
import * as Y from 'yjs';
import { blameLines, TEXT_KEY } from '../shared/blame';
import { listThreads } from '../shared/comments';
import { parseUsername } from '../mcp/identity';
import { ConflictError, NotFoundError, type StoredSnapshot, type Vault } from './vault';
import { publicOrigin } from './cli-routes';
import type { RoomRegistry } from './rooms';

export function apiError(error: unknown): Response {
Expand Down Expand Up @@ -360,6 +363,35 @@ export async function handleProjectsApi(
return Response.json({ project, query, matches });
}

// /api/projects/:p/mcp-config?username=<owner/agent> — everything needed to
// wire an MCP peer to this project, rendered with the caller-visible origin
// (reverse-proxy aware, like /install.sh).
if (segments[3] === 'mcp-config' && segments.length === 4) {
if (req.method !== 'GET') {
return methodNotAllowed();
}
if (!(await vault.listProjects()).includes(project)) {
throw new NotFoundError(`Project "${project}" does not exist.`);
}
const username = url.searchParams.get('username') || 'you/agent';
parseUsername(username); // same validation the MCP applies at startup → 400
const server = publicOrigin(req, url);
return Response.json({
project,
server,
username,
install: `curl -fsSL ${server}/install.sh | sh`,
configure: `mdio mcp install --server ${server} --username ${username} --project ${project} && mdio skill install`,
mcpServers: {
mdio: {
command: 'mdio',
args: ['mcp'],
env: { MDIO_SERVER: server, MDIO_USERNAME: username, MDIO_PROJECT: project },
},
},
});
}

if (segments[3] !== 'docs') {
return null;
}
Expand Down
47 changes: 47 additions & 0 deletions tests/crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,50 @@ describe('project search', () => {
expect((await call('GET', '/api/projects/no-such-project/search?q=x')).status).toBe(404);
});
});

describe('project mcp config', () => {
interface McpConfig {
project: string;
server: string;
username: string;
install: string;
configure: string;
mcpServers: { mdio: { command: string; args: string[]; env: Record<string, string> } };
}

test('returns ready-to-paste wiring scoped to the project', async () => {
await apiCreateProject(server, 'wired');
const response = await call('GET', '/api/projects/wired/mcp-config?username=plosson/claude');
expect(response.status).toBe(200);
const config = (await response.json()) as McpConfig;
expect(config.mcpServers.mdio.command).toBe('mdio');
expect(config.mcpServers.mdio.args).toEqual(['mcp']);
expect(config.mcpServers.mdio.env).toEqual({
MDIO_SERVER: server.url,
MDIO_USERNAME: 'plosson/claude',
MDIO_PROJECT: 'wired',
});
expect(config.install).toInclude(`${server.url}/install.sh`);
expect(config.configure).toInclude('--project wired');
expect(config.configure).toInclude('--username plosson/claude');
expect(config.configure).toInclude('mdio skill install');
});

test('advertises the reverse-proxied origin, not the bind address', async () => {
const response = await fetch(`${server.url}/api/projects/wired/mcp-config`, {
headers: { 'X-Forwarded-Proto': 'https', 'X-Forwarded-Host': 'mdio.example.com' },
});
expect(response.status).toBe(200);
const config = (await response.json()) as McpConfig;
expect(config.server).toBe('https://mdio.example.com');
expect(config.mcpServers.mdio.env.MDIO_SERVER).toBe('https://mdio.example.com');
expect(config.username).toBe('you/agent'); // placeholder when none given
});

test('unknown project 404, invalid username 400, wrong method 405', async () => {
expect((await call('GET', '/api/projects/no-such-project/mcp-config')).status).toBe(404);
expect((await call('GET', '/api/projects/wired/mcp-config?username=a/b/c')).status).toBe(400);
expect((await call('GET', '/api/projects/wired/mcp-config?username=has%20space')).status).toBe(400);
expect((await call('POST', '/api/projects/wired/mcp-config', {})).status).toBe(405);
});
});
Loading
Loading