@stage-labs/metro 0.1.0-beta.295 → 0.1.0-beta.296
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/diagnostics.ts +102 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/raw-get-stream.ts +29 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +47 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +26 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +2 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.296",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { bufferedSince } from '@metro-labs/core/events';
|
|
4
|
+
import { STATIONS } from '@metro-labs/core/station-names';
|
|
5
|
+
import { accountEnabled, accountFromLine, knownAccounts } from '../agents/map.js';
|
|
6
|
+
import { eventInScope } from '../agents/scope.js';
|
|
7
|
+
import type { GetStreamDiagnostic } from './raw-get-stream.js';
|
|
8
|
+
|
|
9
|
+
export const MCP_STATUS_PATH = '/mcp/status';
|
|
10
|
+
|
|
11
|
+
export const diagnosticTag = (value: string): string =>
|
|
12
|
+
createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
13
|
+
|
|
14
|
+
export interface SessionDiagnostic {
|
|
15
|
+
tag: string;
|
|
16
|
+
openedAt: string;
|
|
17
|
+
transportInitialized: boolean;
|
|
18
|
+
clientInitializedAt: string | null;
|
|
19
|
+
client: { kind: 'claude-code' | 'metro-sdk-runner' | 'other'; tag: string } | null;
|
|
20
|
+
subscribed: boolean;
|
|
21
|
+
streamAttached: boolean;
|
|
22
|
+
lastStreamAttachedAt: string | null;
|
|
23
|
+
lastStreamDetachedAt: string | null;
|
|
24
|
+
stream: GetStreamDiagnostic | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SlotDiagnostic {
|
|
28
|
+
started: boolean;
|
|
29
|
+
liveEvents: boolean;
|
|
30
|
+
current: SessionDiagnostic | null;
|
|
31
|
+
replacements: { at: string; reason: 'initialize' | 'adopt'; tag: string; previous: SessionDiagnostic | null }[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DiagnosticSlot {
|
|
35
|
+
diagnostics: (scope: Set<string>) => SlotDiagnostic;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IncomingDiagnostic extends SlotDiagnostic {
|
|
39
|
+
version: 1;
|
|
40
|
+
observedAt: string;
|
|
41
|
+
accounts: {
|
|
42
|
+
station: string;
|
|
43
|
+
tag: string;
|
|
44
|
+
receiveEnabled: boolean;
|
|
45
|
+
bufferedEvents: number;
|
|
46
|
+
latestEventAt: string | null;
|
|
47
|
+
latestEventTag: string | null;
|
|
48
|
+
}[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clientDiagnostic(client: { name: string; version: string } | undefined): SessionDiagnostic['client'] {
|
|
52
|
+
if (client === undefined) return null;
|
|
53
|
+
const kind = client.name === 'claude-code' || client.name === 'metro-sdk-runner' ? client.name : 'other';
|
|
54
|
+
return { kind, tag: diagnosticTag(JSON.stringify([client.name, client.version])) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const eventDate = (raw: unknown): string | null => {
|
|
58
|
+
if (typeof raw !== 'string') return null;
|
|
59
|
+
const time = Date.parse(raw);
|
|
60
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const eventTag = (messageId: unknown, id: unknown): string | null => {
|
|
64
|
+
const value = typeof messageId === 'string' && messageId !== '' ? messageId : id;
|
|
65
|
+
return typeof value === 'string' && value !== '' ? diagnosticTag(value) : null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function incomingDiagnostic(slot: DiagnosticSlot, scope: Set<string>): IncomingDiagnostic {
|
|
69
|
+
const events = bufferedSince(-1).filter(({ event }) => typeof event.line === 'string' && eventInScope(scope, event.line));
|
|
70
|
+
const accounts = knownAccounts().filter((account) => scope.has(account.agentId) && STATIONS.some((station) => station === account.station));
|
|
71
|
+
return {
|
|
72
|
+
version: 1,
|
|
73
|
+
observedAt: new Date().toISOString(),
|
|
74
|
+
...slot.diagnostics(scope),
|
|
75
|
+
accounts: accounts.map((account) => {
|
|
76
|
+
const buffered = events.filter(({ event }) => {
|
|
77
|
+
const target = accountFromLine(event.line);
|
|
78
|
+
return target?.station === account.station && target.accountId === account.id;
|
|
79
|
+
});
|
|
80
|
+
const latest = buffered.at(-1)?.event;
|
|
81
|
+
return {
|
|
82
|
+
station: account.station,
|
|
83
|
+
tag: diagnosticTag(JSON.stringify([account.station, account.id])),
|
|
84
|
+
receiveEnabled: accountEnabled(account.station, account.id),
|
|
85
|
+
bufferedEvents: buffered.length,
|
|
86
|
+
latestEventAt: latest === undefined ? null : eventDate(latest.ts),
|
|
87
|
+
latestEventTag: latest === undefined ? null : eventTag(latest.messageId, latest.id),
|
|
88
|
+
};
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function handleIncomingDiagnostic(req: IncomingMessage, res: ServerResponse, slot: DiagnosticSlot, scope: Set<string>): boolean {
|
|
94
|
+
if ((req.url ?? '').split('?')[0] !== MCP_STATUS_PATH) return false;
|
|
95
|
+
if (req.method !== 'GET') {
|
|
96
|
+
res.writeHead(405, { allow: 'GET' }).end();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
const body = JSON.stringify(incomingDiagnostic(slot, scope));
|
|
100
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(body);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
authenticate,
|
|
9
9
|
runWithIdentity,
|
|
10
10
|
type RequestIdentity,
|
|
11
|
+
allowedAgents,
|
|
11
12
|
} from './request-identity.js';
|
|
12
13
|
import {
|
|
13
14
|
headerValue,
|
|
@@ -18,6 +19,7 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
18
19
|
import { channelLog, type McpSession } from './session.js';
|
|
19
20
|
import { SessionSlot } from './session-slot.js';
|
|
20
21
|
import { connectCallBridge } from './call-bridge.js';
|
|
22
|
+
import { handleIncomingDiagnostic } from './diagnostics.js';
|
|
21
23
|
|
|
22
24
|
const isInitialize = (b: unknown): boolean =>
|
|
23
25
|
!!b &&
|
|
@@ -128,6 +130,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
128
130
|
res.writeHead(401).end('unauthorized');
|
|
129
131
|
return;
|
|
130
132
|
}
|
|
133
|
+
if (handleIncomingDiagnostic(req, res, slot, allowedAgents(identity))) return;
|
|
131
134
|
const parsed = await readOrReject(req, res);
|
|
132
135
|
if (!parsed.ok) return;
|
|
133
136
|
await runWithIdentity(identity, async () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import type {
|
|
3
4
|
StreamableHTTPServerTransport,
|
|
4
5
|
EventId,
|
|
@@ -49,10 +50,19 @@ export const headerValue = (
|
|
|
49
50
|
return typeof raw === 'string' ? raw : undefined;
|
|
50
51
|
};
|
|
51
52
|
|
|
53
|
+
export interface GetStreamDiagnostic {
|
|
54
|
+
tag: string;
|
|
55
|
+
openedAt: string;
|
|
56
|
+
attachedAt: string | null;
|
|
57
|
+
closedAt: string | null;
|
|
58
|
+
ownsMapping: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
export interface RawGetSink {
|
|
53
62
|
closed: boolean;
|
|
54
63
|
attach: (transport: StreamableHTTPServerTransport) => void;
|
|
55
64
|
close: () => void;
|
|
65
|
+
diagnostics: () => GetStreamDiagnostic;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
const streamEnded = (sink: RawGetSink, res: ServerResponse): boolean =>
|
|
@@ -99,10 +109,8 @@ export interface ServeOpts {
|
|
|
99
109
|
previous?: () => RawGetSink | undefined;
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
|
|
103
|
-
const { transport, eventStore, scope, req, res, log, registerSink } = opts;
|
|
112
|
+
function primeStream(transport: StreamableHTTPServerTransport, res: ServerResponse): void {
|
|
104
113
|
const sessionId = web(transport)?.sessionId;
|
|
105
|
-
|
|
106
114
|
res.writeHead(200, {
|
|
107
115
|
'content-type': 'text/event-stream',
|
|
108
116
|
'cache-control': 'no-cache, no-transform',
|
|
@@ -113,13 +121,23 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
|
|
|
113
121
|
res.flushHeaders?.();
|
|
114
122
|
res.write(RETRY_DIRECTIVE);
|
|
115
123
|
res.write(COMMENT);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
|
|
127
|
+
const { transport, eventStore, scope, req, res, log, registerSink } = opts;
|
|
128
|
+
primeStream(transport, res);
|
|
116
129
|
|
|
117
130
|
let active = transport;
|
|
118
131
|
let entry: StreamEntry | undefined;
|
|
132
|
+
const tag = randomUUID();
|
|
133
|
+
const openedAt = new Date().toISOString();
|
|
134
|
+
let attachedAt: string | null = null;
|
|
135
|
+
let closedAt: string | null = null;
|
|
119
136
|
const timers: { keepalive?: ReturnType<typeof setInterval> } = {};
|
|
120
137
|
const closeStream = (): void => {
|
|
121
138
|
if (sink.closed) return;
|
|
122
139
|
sink.closed = true;
|
|
140
|
+
closedAt = new Date().toISOString();
|
|
123
141
|
if (timers.keepalive) clearInterval(timers.keepalive);
|
|
124
142
|
const mapping = web(active)?._streamMapping;
|
|
125
143
|
if (entry !== undefined && mapping?.get(STANDALONE_STREAM_ID) === entry) {
|
|
@@ -150,8 +168,16 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
|
|
|
150
168
|
cleanup: closeStream,
|
|
151
169
|
};
|
|
152
170
|
web(next)?._streamMapping?.set(STANDALONE_STREAM_ID, entry);
|
|
171
|
+
attachedAt = new Date().toISOString();
|
|
153
172
|
},
|
|
154
173
|
close: closeStream,
|
|
174
|
+
diagnostics: () => ({
|
|
175
|
+
tag,
|
|
176
|
+
openedAt,
|
|
177
|
+
attachedAt,
|
|
178
|
+
closedAt,
|
|
179
|
+
ownsMapping: entry !== undefined && web(active)?._streamMapping?.get(STANDALONE_STREAM_ID) === entry,
|
|
180
|
+
}),
|
|
155
181
|
};
|
|
156
182
|
|
|
157
183
|
res.on('close', closeStream);
|
|
@@ -4,6 +4,18 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
4
4
|
import { newReplayLedger } from '../channels/relay.js';
|
|
5
5
|
import { McpSession, channelLog, type SessionKind } from './session.js';
|
|
6
6
|
import { allowedAgents, type RequestIdentity } from './request-identity.js';
|
|
7
|
+
import { diagnosticTag, type SessionDiagnostic, type SlotDiagnostic } from './diagnostics.js';
|
|
8
|
+
|
|
9
|
+
interface SlotReplacement {
|
|
10
|
+
at: string;
|
|
11
|
+
reason: 'initialize' | 'adopt';
|
|
12
|
+
tag: string;
|
|
13
|
+
scope: Set<string>;
|
|
14
|
+
previousScope: Set<string>;
|
|
15
|
+
previous: SessionDiagnostic | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const sharesScope = (left: Set<string>, right: Set<string>): boolean => [...left].some((id) => right.has(id));
|
|
7
19
|
|
|
8
20
|
export class SessionSlot {
|
|
9
21
|
private session: McpSession | undefined;
|
|
@@ -11,6 +23,7 @@ export class SessionSlot {
|
|
|
11
23
|
private started = false;
|
|
12
24
|
private live: boolean;
|
|
13
25
|
private changing: Promise<void> = Promise.resolve();
|
|
26
|
+
private readonly replacements: SlotReplacement[] = [];
|
|
14
27
|
|
|
15
28
|
constructor(
|
|
16
29
|
live = true,
|
|
@@ -27,6 +40,20 @@ export class SessionSlot {
|
|
|
27
40
|
return this.live;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
diagnostics(scope: Set<string>): SlotDiagnostic {
|
|
44
|
+
return {
|
|
45
|
+
started: this.started,
|
|
46
|
+
liveEvents: this.live,
|
|
47
|
+
current: this.session !== undefined && sharesScope(scope, this.session.scope) ? this.session.diagnostics() : null,
|
|
48
|
+
replacements: this.replacements.filter((replacement) => sharesScope(scope, replacement.scope)).map((replacement) => ({
|
|
49
|
+
at: replacement.at,
|
|
50
|
+
reason: replacement.reason,
|
|
51
|
+
tag: replacement.tag,
|
|
52
|
+
previous: sharesScope(scope, replacement.previousScope) ? replacement.previous : null,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
startInbound(): void {
|
|
31
58
|
if (this.started) return;
|
|
32
59
|
this.started = true;
|
|
@@ -59,8 +86,27 @@ export class SessionSlot {
|
|
|
59
86
|
return changed;
|
|
60
87
|
}
|
|
61
88
|
|
|
89
|
+
private previousSlot(): Pick<SlotReplacement, 'previous' | 'previousScope'> {
|
|
90
|
+
return {
|
|
91
|
+
previous: this.session?.diagnostics() ?? null,
|
|
92
|
+
previousScope: this.session?.scope ?? new Set<string>(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private recordReplacement(session: McpSession, adoptId: string | undefined, previous: Pick<SlotReplacement, 'previous' | 'previousScope'>): void {
|
|
97
|
+
this.replacements.push({
|
|
98
|
+
at: new Date().toISOString(),
|
|
99
|
+
reason: adoptId === undefined ? 'initialize' : 'adopt',
|
|
100
|
+
tag: diagnosticTag(session.id),
|
|
101
|
+
scope: session.scope,
|
|
102
|
+
...previous,
|
|
103
|
+
});
|
|
104
|
+
if (this.replacements.length > 8) this.replacements.shift();
|
|
105
|
+
}
|
|
106
|
+
|
|
62
107
|
private async replace(identity: RequestIdentity, adoptId?: string): Promise<McpSession> {
|
|
63
108
|
if (adoptId !== undefined && this.session?.id === adoptId) return this.session;
|
|
109
|
+
const previous = this.previousSlot();
|
|
64
110
|
await this.session?.close();
|
|
65
111
|
if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
|
|
66
112
|
const id = adoptId ?? randomUUID();
|
|
@@ -81,6 +127,7 @@ export class SessionSlot {
|
|
|
81
127
|
},
|
|
82
128
|
});
|
|
83
129
|
this.session = session;
|
|
130
|
+
this.recordReplacement(session, adoptId, previous);
|
|
84
131
|
if (this.started && this.live) session.startChannel();
|
|
85
132
|
channelLog('session: opened', 'id', id, 'kind', this.kind, 'adopted', adoptId !== undefined);
|
|
86
133
|
return session;
|
|
@@ -15,6 +15,7 @@ import { registerToolHandlers, toolSchemaSignature } from './tool-dispatch.js';
|
|
|
15
15
|
import { web, type RawGetSink } from './raw-get-stream.js';
|
|
16
16
|
import { sharedCalls } from '../voice/shared.js';
|
|
17
17
|
import { CALL_STATE } from '@metro-labs/core/call';
|
|
18
|
+
import { clientDiagnostic, diagnosticTag, type SessionDiagnostic } from './diagnostics.js';
|
|
18
19
|
|
|
19
20
|
export const channelLog = (...a: unknown[]): void => {
|
|
20
21
|
console.error('[metro-mcp]', ...a);
|
|
@@ -84,6 +85,10 @@ export class McpSession {
|
|
|
84
85
|
private announcing = false;
|
|
85
86
|
private readonly onClosed: (session: McpSession) => void;
|
|
86
87
|
private readonly kind: SessionKind;
|
|
88
|
+
private readonly openedAt = new Date().toISOString();
|
|
89
|
+
private clientInitializedAt: string | null = null;
|
|
90
|
+
private lastStreamAttachedAt: string | null = null;
|
|
91
|
+
private lastStreamDetachedAt: string | null = null;
|
|
87
92
|
|
|
88
93
|
private constructor(init: SessionInit) {
|
|
89
94
|
this.id = init.id;
|
|
@@ -102,6 +107,9 @@ export class McpSession {
|
|
|
102
107
|
instructions: voice ? VOICE_MCP_INSTRUCTIONS : MCP_INSTRUCTIONS,
|
|
103
108
|
},
|
|
104
109
|
);
|
|
110
|
+
this.server.oninitialized = (): void => {
|
|
111
|
+
this.clientInitializedAt = new Date().toISOString();
|
|
112
|
+
};
|
|
105
113
|
registerToolHandlers(this.server, {
|
|
106
114
|
markCurrent: (): void => {
|
|
107
115
|
this.issuedSchema = toolSchemaSignature();
|
|
@@ -159,6 +167,21 @@ export class McpSession {
|
|
|
159
167
|
return this.sink;
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
diagnostics(): SessionDiagnostic {
|
|
171
|
+
return {
|
|
172
|
+
tag: diagnosticTag(this.id),
|
|
173
|
+
openedAt: this.openedAt,
|
|
174
|
+
transportInitialized: web(this.transport)?._initialized === true,
|
|
175
|
+
clientInitializedAt: this.clientInitializedAt,
|
|
176
|
+
client: clientDiagnostic(this.server.getClientVersion()),
|
|
177
|
+
subscribed: this.unsubscribe !== undefined,
|
|
178
|
+
streamAttached: this.streamAttached,
|
|
179
|
+
lastStreamAttachedAt: this.lastStreamAttachedAt,
|
|
180
|
+
lastStreamDetachedAt: this.lastStreamDetachedAt,
|
|
181
|
+
stream: this.sink?.diagnostics() ?? null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
inScope(line: string): boolean {
|
|
163
186
|
return this.streamAttached && eventInScope(this.scope, line);
|
|
164
187
|
}
|
|
@@ -181,8 +204,10 @@ export class McpSession {
|
|
|
181
204
|
sink?.close();
|
|
182
205
|
return;
|
|
183
206
|
}
|
|
207
|
+
if (sink === undefined && this.sink !== undefined) this.lastStreamDetachedAt = new Date().toISOString();
|
|
184
208
|
this.sink = sink;
|
|
185
209
|
if (sink !== undefined) {
|
|
210
|
+
this.lastStreamAttachedAt = new Date().toISOString();
|
|
186
211
|
this.announceCallState();
|
|
187
212
|
this.announceToolSchema();
|
|
188
213
|
}
|
|
@@ -227,6 +252,7 @@ export class McpSession {
|
|
|
227
252
|
|
|
228
253
|
dropStream(): void {
|
|
229
254
|
const sink = this.sink;
|
|
255
|
+
if (sink !== undefined) this.lastStreamDetachedAt = new Date().toISOString();
|
|
230
256
|
this.sink = undefined;
|
|
231
257
|
sink?.close();
|
|
232
258
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type ServerResponse,
|
|
13
13
|
} from 'node:http';
|
|
14
14
|
import { Line } from '@metro-labs/core/lines';
|
|
15
|
+
import { MCP_STATUS_PATH } from '../mcp/diagnostics.js';
|
|
15
16
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
16
17
|
import {
|
|
17
18
|
classifyEvent,
|
|
@@ -214,7 +215,7 @@ export async function startWebhookServer(
|
|
|
214
215
|
|
|
215
216
|
function isMcpPath(req: IncomingMessage): boolean {
|
|
216
217
|
const path = (req.url ?? '').split('?')[0];
|
|
217
|
-
return path === '/' || path === '/mcp' || path === VOICE_MCP_PATH;
|
|
218
|
+
return path === '/' || path === '/mcp' || path === VOICE_MCP_PATH || path === MCP_STATUS_PATH;
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
function flatHeaders(req: IncomingMessage): Record<string, string> {
|
package/runtime/runtime.json
CHANGED