Skip to content

Commit 7d9473e

Browse files
committed
feat(mcp): Server Card + Catalog discovery documents (#107)
Adds connection-less discovery metadata to the Streamable HTTP transport (SEP-2127 / SEP-1649) so registries and crawlers can learn about the server before connecting: - Server Card at GET {path}/server-card (media type application/mcp-server-card+json) — reverse-DNS name, version, description, and the streamable-http remote endpoint with supportedProtocolVersions. - MCP Catalog at GET /.well-known/mcp/catalog.json — site-wide index whose entry points at the Server Card (urn:air:{publisher}:{name}). Both endpoints are public (unauthenticated, CORS-enabled, cacheable) and served before the auth check, like robots.txt / OAuth metadata. URLs derive from QVERIS_MCP_PUBLIC_URL or the request (honoring X-Forwarded-Proto) so they're correct behind a TLS proxy. Card metadata comes from package.json (mcpName, version, description, homepage, repository) + the SDK's SUPPORTED_PROTOCOL_VERSIONS. Pure builders in server-card.ts are unit-tested; the endpoints are covered end-to-end (media type, CORS, preflight, public access under an auth token, PUBLIC_URL override, 404 when no card info). 98 MCP tests pass. Full OAuth 2.1 remains the last #107 follow-up.
1 parent 0af4daf commit 7d9473e

6 files changed

Lines changed: 370 additions & 5 deletions

File tree

packages/mcp/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,11 @@ npx -y @qverisai/mcp
296296
- **Inbound auth:** set `QVERIS_MCP_HTTP_AUTH_TOKEN` to require `Authorization: Bearer <token>` on the MCP endpoint. The server **refuses to start** when binding a non-loopback host without a token, unless you set `QVERIS_MCP_HTTP_ALLOW_UNAUTHENTICATED=true` to delegate auth to an external proxy/gateway. Your `QVERIS_API_KEY` is the server's *outbound* credential to QVeris — it is **not** an inbound check, so anyone reaching an unauthenticated endpoint would spend your credits.
297297
- DNS-rebinding protection is **on by default** (localhost + the bound host/port are allow-listed). When exposing the server publicly, add your public host via `QVERIS_MCP_ALLOWED_HOSTS`.
298298
- Requests are capped at 4 MiB by default (`QVERIS_MCP_MAX_BODY_BYTES`), and idle sessions are evicted after 5 minutes (`QVERIS_MCP_SESSION_TIMEOUT_MS`).
299-
- Full OAuth 2.1 and a `.well-known` MCP Server Card are tracked as follow-ups in [#107](https://github.com/QVerisAI/qveris-agent-toolkit/issues/107).
299+
- **Discovery:** registries and crawlers can learn about the server without connecting:
300+
- **Server Card** at `GET {path}/server-card` (default `/mcp/server-card`), media type `application/mcp-server-card+json` — server identity, version, and the remote endpoint.
301+
- **MCP Catalog** at `GET /.well-known/mcp/catalog.json` — a site-wide index pointing at the Server Card.
302+
- Both are public (unauthenticated, CORS-enabled), even when an auth token is set. Behind a TLS proxy, set `QVERIS_MCP_PUBLIC_URL` (or send `X-Forwarded-Proto`) so the advertised URLs use your public origin.
303+
- Full OAuth 2.1 authorization is tracked as a follow-up in [#107](https://github.com/QVerisAI/qveris-agent-toolkit/issues/107).
300304

301305
## Environment Variables
302306

@@ -317,6 +321,7 @@ npx -y @qverisai/mcp
317321
| `QVERIS_MCP_HTTP_ALLOW_UNAUTHENTICATED` | | `true` to allow a non-loopback bind without a token (auth delegated externally) |
318322
| `QVERIS_MCP_MAX_BODY_BYTES` | | Max request body size in bytes (default `4194304`) |
319323
| `QVERIS_MCP_SESSION_TIMEOUT_MS` | | Idle session TTL in ms (default `300000`) |
324+
| `QVERIS_MCP_PUBLIC_URL` | | Public origin advertised in discovery documents (e.g. `https://mcp.example.com`) |
320325

321326
## Region
322327

packages/mcp/src/http.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,30 @@ describe('startHttpServer (end-to-end over Streamable HTTP)', () => {
105105
running = undefined;
106106
});
107107

108-
async function startServer(extraEnv: Record<string, string> = {}): Promise<void> {
108+
const CARD_INFO = {
109+
name: 'io.github.QVerisAI/mcp',
110+
version: '9.9.9',
111+
description: 'QVeris MCP server.',
112+
title: 'QVeris',
113+
websiteUrl: 'https://qveris.ai',
114+
protocolVersions: ['2025-11-25'],
115+
};
116+
117+
async function startServer(
118+
extraEnv: Record<string, string> = {},
119+
cardInfo?: typeof CARD_INFO,
120+
): Promise<void> {
109121
const config = resolveTransportConfig(
110122
{ QVERIS_MCP_TRANSPORT: 'http', QVERIS_MCP_HTTP_PORT: '0', ...extraEnv },
111123
[],
112124
);
113125
// Server has no QVERIS_API_KEY, so tool listing works but calls return an
114126
// actionable error — exactly the credential-less path we want to exercise.
115-
running = await startHttpServer(config, (sessionId) => createQverisServer(undefined, sessionId));
127+
running = await startHttpServer(
128+
config,
129+
(sessionId) => createQverisServer(undefined, sessionId),
130+
cardInfo,
131+
);
116132
}
117133

118134
async function connectClient(
@@ -251,4 +267,54 @@ describe('startHttpServer (end-to-end over Streamable HTTP)', () => {
251267
expect(res.status).toBe(200);
252268
await res.text();
253269
});
270+
271+
it('serves the Server Card unauthenticated with the right media type and CORS', async () => {
272+
// A token is set to prove the card is reachable WITHOUT credentials.
273+
await startServer({ QVERIS_MCP_HTTP_AUTH_TOKEN: 'tok' }, CARD_INFO);
274+
const res = await fetch(`http://127.0.0.1:${running!.port}/mcp/server-card`, {
275+
headers: { Accept: 'application/mcp-server-card+json' },
276+
});
277+
expect(res.status).toBe(200);
278+
expect(res.headers.get('content-type')).toContain('application/mcp-server-card+json');
279+
expect(res.headers.get('access-control-allow-origin')).toBe('*');
280+
const card = (await res.json()) as Record<string, unknown>;
281+
expect(card.name).toBe('io.github.QVerisAI/mcp');
282+
expect(card.version).toBe('9.9.9');
283+
expect((card.remotes as Array<{ url: string }>)[0].url).toBe(`http://127.0.0.1:${running!.port}/mcp`);
284+
});
285+
286+
it('serves the MCP Catalog pointing at the Server Card', async () => {
287+
await startServer({}, CARD_INFO);
288+
const res = await fetch(`http://127.0.0.1:${running!.port}/.well-known/mcp/catalog.json`);
289+
expect(res.status).toBe(200);
290+
const catalog = (await res.json()) as { specVersion: string; entries: Array<Record<string, string>> };
291+
expect(catalog.specVersion).toBe('draft');
292+
expect(catalog.entries[0].mediaType).toBe('application/mcp-server-card+json');
293+
expect(catalog.entries[0].url).toBe(`http://127.0.0.1:${running!.port}/mcp/server-card`);
294+
expect(catalog.entries[0].identifier).toBe('urn:air:qveris.ai:mcp');
295+
});
296+
297+
it('answers a CORS preflight for the Server Card', async () => {
298+
await startServer({}, CARD_INFO);
299+
const res = await fetch(`http://127.0.0.1:${running!.port}/mcp/server-card`, { method: 'OPTIONS' });
300+
expect(res.status).toBe(204);
301+
expect(res.headers.get('access-control-allow-methods')).toContain('GET');
302+
await res.text();
303+
});
304+
305+
it('does not serve discovery endpoints when no card info is provided', async () => {
306+
await startServer(); // no cardInfo
307+
const res = await fetch(`http://127.0.0.1:${running!.port}/mcp/server-card`, {
308+
headers: { Accept: 'application/mcp-server-card+json' },
309+
});
310+
expect(res.status).toBe(404);
311+
await res.text();
312+
});
313+
314+
it('honors QVERIS_MCP_PUBLIC_URL in discovery URLs (behind a proxy)', async () => {
315+
await startServer({ QVERIS_MCP_PUBLIC_URL: 'https://mcp.example.com' }, CARD_INFO);
316+
const res = await fetch(`http://127.0.0.1:${running!.port}/mcp/server-card`);
317+
const card = (await res.json()) as { remotes: Array<{ url: string }> };
318+
expect(card.remotes[0].url).toBe('https://mcp.example.com/mcp');
319+
});
254320
});

packages/mcp/src/http.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
3030
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3131
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
3232

33+
import {
34+
buildCatalog,
35+
buildServerCard,
36+
CATALOG_PATH,
37+
SERVER_CARD_MEDIA_TYPE,
38+
type ServerCardInfo,
39+
} from './server-card.js';
40+
3341
/** Transport selection plus the HTTP-mode settings. */
3442
export interface TransportConfig {
3543
mode: 'stdio' | 'http';
@@ -53,6 +61,8 @@ export interface TransportConfig {
5361
maxBodyBytes: number;
5462
/** Idle session TTL in ms; stale sessions are closed and evicted. */
5563
sessionTimeoutMs: number;
64+
/** Public origin (e.g. behind a TLS proxy) used in discovery documents. */
65+
publicUrl?: string;
5666
}
5767

5868
const DEFAULT_PORT = 3000;
@@ -171,9 +181,21 @@ export function resolveTransportConfig(
171181
allowUnauthenticated: parseBool(env.QVERIS_MCP_HTTP_ALLOW_UNAUTHENTICATED, false),
172182
maxBodyBytes: parsePositiveInt(env.QVERIS_MCP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES),
173183
sessionTimeoutMs: parsePositiveInt(env.QVERIS_MCP_SESSION_TIMEOUT_MS, DEFAULT_SESSION_TIMEOUT_MS),
184+
publicUrl: env.QVERIS_MCP_PUBLIC_URL?.trim().replace(/\/+$/, '') || undefined,
174185
};
175186
}
176187

188+
/** Absolute origin for discovery URLs: the configured public origin, or derived
189+
* from the request (honoring an `X-Forwarded-Proto` from a TLS-terminating proxy). */
190+
function requestOrigin(req: IncomingMessage, publicUrl: string | undefined): string {
191+
if (publicUrl) return publicUrl;
192+
const forwarded = req.headers['x-forwarded-proto'];
193+
const proto =
194+
(typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : undefined) || 'http';
195+
const host = req.headers.host ?? 'localhost';
196+
return `${proto}://${host}`;
197+
}
198+
177199
/** Read and JSON-parse a request body with a hard size cap. */
178200
async function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
179201
const chunks: Buffer[] = [];
@@ -234,6 +256,8 @@ export interface RunningHttpServer {
234256
* @param config - Resolved HTTP transport settings.
235257
* @param makeServer - Factory that builds a fresh Qveris {@link Server} for a
236258
* new client session. Called once per `initialize`.
259+
* @param cardInfo - Metadata for the discovery documents (Server Card + Catalog).
260+
* When omitted those endpoints are not served.
237261
* @param logger - Where to write startup/lifecycle lines (defaults to stderr,
238262
* keeping stdout clean).
239263
* @throws if binding a non-loopback host without an auth token and without an
@@ -242,6 +266,7 @@ export interface RunningHttpServer {
242266
export async function startHttpServer(
243267
config: TransportConfig,
244268
makeServer: (sessionId: string) => Server,
269+
cardInfo?: ServerCardInfo,
245270
logger: (message: string) => void = (message) => process.stderr.write(message),
246271
): Promise<RunningHttpServer> {
247272
const nonLoopback = !isLoopbackHost(config.host);
@@ -329,6 +354,40 @@ export async function startHttpServer(
329354
return;
330355
}
331356

357+
// Public, unauthenticated discovery documents (Server Card + Catalog).
358+
// Like robots.txt / OAuth metadata, these are meant to be crawled without
359+
// credentials, so they are handled before the auth check.
360+
const cardPath = `${config.path}/server-card`;
361+
if (cardInfo && (url.pathname === cardPath || url.pathname === CATALOG_PATH)) {
362+
const cors = {
363+
'Access-Control-Allow-Origin': '*',
364+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
365+
'Access-Control-Allow-Headers': 'Accept',
366+
};
367+
if (req.method === 'OPTIONS') {
368+
req.resume();
369+
res.writeHead(204, { ...cors, 'Access-Control-Max-Age': '86400' });
370+
res.end();
371+
return;
372+
}
373+
if (req.method === 'GET') {
374+
const origin = requestOrigin(req, config.publicUrl);
375+
const cache = { 'Cache-Control': 'public, max-age=3600' };
376+
if (url.pathname === cardPath) {
377+
const card = buildServerCard(cardInfo, `${origin}${config.path}`);
378+
writeJson(res, 200, card, { ...cors, ...cache, 'Content-Type': SERVER_CARD_MEDIA_TYPE });
379+
} else {
380+
const catalog = buildCatalog(cardInfo, `${origin}${cardPath}`);
381+
writeJson(res, 200, catalog, { ...cors, ...cache });
382+
}
383+
return;
384+
}
385+
req.resume();
386+
res.writeHead(405, { Allow: 'GET, OPTIONS' });
387+
res.end();
388+
return;
389+
}
390+
332391
if (url.pathname !== config.path) {
333392
req.resume(); // drain the body so the connection can be reused/closed
334393
writeJson(res, 404, jsonRpcError(null, -32601, `Not found: ${url.pathname}`));

packages/mcp/src/index.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
2929
import {
3030
CallToolRequestSchema,
3131
ListToolsRequestSchema,
32+
SUPPORTED_PROTOCOL_VERSIONS,
3233
type CallToolResult,
3334
} from '@modelcontextprotocol/sdk/types.js';
3435
import { resolveTransportConfig, startHttpServer } from './http.js';
36+
import type { ServerCardInfo } from './server-card.js';
3537
import { realpathSync } from 'node:fs';
3638
import { pathToFileURL } from 'node:url';
3739
import { v4 as uuidv4 } from 'uuid';
@@ -72,7 +74,26 @@ import { createRequire } from 'node:module';
7274

7375
const SERVER_NAME = 'qveris';
7476
const require = createRequire(import.meta.url);
75-
const { version: SERVER_VERSION } = require('../package.json');
77+
const pkg = require('../package.json');
78+
const SERVER_VERSION: string = pkg.version;
79+
80+
/** Discovery metadata (Server Card + Catalog) derived from package.json. */
81+
function buildServerCardInfo(): ServerCardInfo {
82+
const repoUrl: string | undefined = pkg.repository?.url
83+
?.replace(/^git\+/, '')
84+
.replace(/\.git$/, '');
85+
return {
86+
name: pkg.mcpName ?? 'io.github.QVerisAI/mcp',
87+
version: SERVER_VERSION,
88+
description: pkg.description,
89+
title: 'QVeris',
90+
websiteUrl: pkg.homepage,
91+
repository: repoUrl
92+
? { source: 'github', url: repoUrl, subfolder: pkg.repository?.directory }
93+
: undefined,
94+
protocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
95+
};
96+
}
7697

7798
/**
7899
* List the MCP tools exposed by this server.
@@ -479,7 +500,11 @@ export async function main(): Promise<void> {
479500

480501
// Streamable HTTP: one Qveris server per MCP session, keyed by Mcp-Session-Id.
481502
if (transportConfig.mode === 'http') {
482-
await startHttpServer(transportConfig, (sessionId) => createQverisServer(client, sessionId));
503+
await startHttpServer(
504+
transportConfig,
505+
(sessionId) => createQverisServer(client, sessionId),
506+
buildServerCardInfo(),
507+
);
483508
return;
484509
}
485510

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
buildCatalog,
5+
buildServerCard,
6+
CATALOG_SPEC_VERSION,
7+
SERVER_CARD_MEDIA_TYPE,
8+
SERVER_CARD_SCHEMA_URL,
9+
type ServerCardInfo,
10+
} from './server-card.js';
11+
12+
const INFO: ServerCardInfo = {
13+
name: 'io.github.QVerisAI/mcp',
14+
version: '1.2.3',
15+
description: 'QVeris MCP server.',
16+
title: 'QVeris',
17+
websiteUrl: 'https://qveris.ai',
18+
repository: { source: 'github', url: 'https://github.com/QVerisAI/qveris-agent-toolkit', subfolder: 'packages/mcp' },
19+
protocolVersions: ['2025-06-18', '2025-11-25'],
20+
};
21+
22+
// The reverse-DNS name pattern from the Server Card schema.
23+
const NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
24+
25+
describe('buildServerCard', () => {
26+
it('produces a schema-valid card with the required fields and remote', () => {
27+
const card = buildServerCard(INFO, 'https://mcp.example.com/mcp');
28+
29+
expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL);
30+
expect(card.name).toBe('io.github.QVerisAI/mcp');
31+
expect(card.name).toMatch(NAME_PATTERN);
32+
expect(card.version).toBe('1.2.3');
33+
expect(card.description).toBe('QVeris MCP server.');
34+
expect(card.title).toBe('QVeris');
35+
expect(card.websiteUrl).toBe('https://qveris.ai');
36+
expect(card.repository).toEqual(INFO.repository);
37+
expect(card.remotes).toEqual([
38+
{
39+
type: 'streamable-http',
40+
url: 'https://mcp.example.com/mcp',
41+
supportedProtocolVersions: ['2025-06-18', '2025-11-25'],
42+
},
43+
]);
44+
});
45+
46+
it('omits optional fields and the protocol list when absent', () => {
47+
const card = buildServerCard(
48+
{ name: 'example.org/x', version: '1.0.0', description: 'min' },
49+
'http://127.0.0.1:3000/mcp',
50+
);
51+
expect(card.title).toBeUndefined();
52+
expect(card.websiteUrl).toBeUndefined();
53+
expect(card.repository).toBeUndefined();
54+
expect(card.remotes?.[0]).toEqual({ type: 'streamable-http', url: 'http://127.0.0.1:3000/mcp' });
55+
expect(card.remotes?.[0]).not.toHaveProperty('supportedProtocolVersions');
56+
});
57+
});
58+
59+
describe('buildCatalog', () => {
60+
it('anchors the URN on the publisher domain and points at the card URL', () => {
61+
const catalog = buildCatalog(INFO, 'https://mcp.example.com/mcp/server-card');
62+
63+
expect(catalog.specVersion).toBe(CATALOG_SPEC_VERSION);
64+
expect(catalog.entries).toEqual([
65+
{
66+
identifier: 'urn:air:qveris.ai:mcp',
67+
displayName: 'QVeris',
68+
mediaType: SERVER_CARD_MEDIA_TYPE,
69+
url: 'https://mcp.example.com/mcp/server-card',
70+
},
71+
]);
72+
});
73+
74+
it('falls back to localhost as the publisher when no website URL is set', () => {
75+
const catalog = buildCatalog(
76+
{ name: 'example.org/weather', version: '1.0.0', description: 'x' },
77+
'http://127.0.0.1:3000/mcp/server-card',
78+
);
79+
expect(catalog.entries[0].identifier).toBe('urn:air:localhost:weather');
80+
expect(catalog.entries[0].displayName).toBe('example.org/weather');
81+
});
82+
});

0 commit comments

Comments
 (0)