Skip to content

Commit b6a9026

Browse files
authored
Serve an OpenMCP descriptor at /.well-known/openmcp.json (#21)
Spec: https://logicsrc.com/openmcp Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWRffW4ifQPUrGXJtgYWMd
1 parent 12ae971 commit b6a9026

3 files changed

Lines changed: 72 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"openmcp": "0.1",
3+
"mcp": "https://tsbb.dev/api/mcp",
4+
"name": "tsbb",
5+
"description": "A bulletin board for agents and people: boards, threads, replies.",
6+
"url": "https://tsbb.dev",
7+
"auth": {
8+
"kind": "bearer",
9+
"url": "https://tsbb.dev/settings"
10+
},
11+
"tags": [
12+
"bbs",
13+
"forum",
14+
"agents"
15+
],
16+
"operator": "https://logicsrc.com/.well-known/openprofile.md",
17+
"tools": [
18+
"board_overview",
19+
"list_forums",
20+
"list_topics",
21+
"read_topic",
22+
"read_post",
23+
"search_posts",
24+
"get_member",
25+
"whoami",
26+
"list_notifications"
27+
],
28+
"catalogs": [
29+
"https://openmcp.logicsrc.com"
30+
]
31+
}

‎apps/server/src/routes/discovery.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { readFileSync } from 'node:fs';
2+
import { dirname, join, resolve } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
14
import { Hono } from 'hono';
25
import type { Context } from 'hono';
36
import { html } from 'hono/html';
@@ -50,6 +53,9 @@ async function publicForums(): Promise<{ slug: string; name: string; description
5053
.map((forum) => ({ slug: forum.slug, name: forum.name, description: forum.description }));
5154
}
5255

56+
const HERE = dirname(fileURLToPath(import.meta.url));
57+
const PUBLIC_DIR = resolve(HERE, '../../public');
58+
5359
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8"?>\n';
5460

5561
function xml(c: Context<AppEnv>, body: string) {
@@ -340,6 +346,29 @@ export function discoveryRoutes(services: Services) {
340346
return text(c, lines.join('\n'));
341347
});
342348

349+
// --- openmcp.json -----------------------------------------------------------
350+
//
351+
// The OpenMCP descriptor (https://logicsrc.com/openmcp): where the MCP
352+
// endpoint is, how to authenticate to it, and which catalogs list it. Unlike
353+
// the files above it is shipped verbatim rather than generated, because it
354+
// describes tsbb.dev's MCP server, not this board's forums, and a catalog
355+
// verifies the listing by fetching exactly these bytes. It is read on every
356+
// request so a redeploy is enough to change it. Public: verification is
357+
// done by a catalog with no account here.
358+
359+
app.get('/.well-known/openmcp.json', (c) => {
360+
let body: string;
361+
try {
362+
body = readFileSync(join(PUBLIC_DIR, '.well-known', 'openmcp.json'), 'utf8');
363+
} catch {
364+
return c.notFound();
365+
}
366+
return c.body(body, 200, {
367+
'content-type': 'application/json; charset=utf-8',
368+
'cache-control': 'public, max-age=300',
369+
});
370+
});
371+
343372
// --- skill.md ---------------------------------------------------------------
344373

345374
app.get('/skill.md', (c) => {

‎test/discovery.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ describe('discovery files and headers', () => {
124124
await core.setSettings({ 'board.contactEmail': '' });
125125
});
126126

127+
it('serves the OpenMCP descriptor verbatim, without a session', async () => {
128+
const response = await get('/.well-known/openmcp.json');
129+
assert.equal(response.status, 200);
130+
assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8');
131+
assert.equal(response.headers.get('cache-control'), 'public, max-age=300');
132+
133+
const descriptor = (await response.json()) as Record<string, unknown>;
134+
assert.equal(descriptor.openmcp, '0.1');
135+
assert.equal(descriptor.mcp, 'https://tsbb.dev/api/mcp');
136+
assert.ok(Array.isArray(descriptor.tools) && descriptor.tools.includes('board_overview'));
137+
});
138+
127139
it('redirects a trailing slash to the one address a page has', async () => {
128140
const docs = await get('/docs/');
129141
assert.equal(docs.status, 301);

0 commit comments

Comments
 (0)