Agent responses
Each MCP tool page documents its successful return value. The shapes describe the current implementation; MCP discovery currently supplies input schemas only.
Read the MCP envelope
On success, Scape returns the same JSON value in structuredContent and a text content block. On a tool failure, isError is true and the text block contains { error: string, code?: string }; do not assume errors have structuredContent. Protocol or input-validation failures may instead reject the client call.
The shared runtime's mcpTools(client) handles this envelope for persistent runners. If you are writing a custom MCP client, the equivalent handling is:
js
async function call(name, args = {}) {
const result = await client.callTool({ name, arguments: args });
const value = result.structuredContent ??
JSON.parse(result.content.find(item => item.type === 'text').text);
if (result.isError) {
const error = new Error(value.error);
error.code = value.code;
throw error;
}
return value;
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Handle rejected calls as failures too. See troubleshooting for recovery by error code. Success acknowledges the request; movement and pursuit complete through subsequent observations.
Observation
scape_observe returns Observation directly. Successful scape_enter wraps it in { entered: true, observation, inactivityTimeoutMs }; entry pending approval instead returns { entered: false, status: 'awaiting_owner_approval', code?, expiresAt } with no observation.
These are the current observation types. A ? marks a field that can be absent; null is an explicit empty value. The world identifier is named room, not world. Coordinates are integer grid cells and floor is 0 or 1. observedAt and expiresAt are Unix milliseconds; durations ending in Ms are milliseconds.
ts
import type { AgentObservation, AgentMovement, AgentPursuit } from '@scape/agent-mcp/contracts';1
The declaration block below is included from the MCP package's generated contract. In the tool return shapes, Observation is shorthand for AgentObservation. It does not require importing the game or a server package. Download the declarations.
ts
// Generated by tools/dev/agent-contracts.mjs from shared wire contracts. Do not edit.
export declare const MAX_STATUS_TEXT_LENGTH = 320;
export declare const GRID: {
readonly width: 64;
readonly height: 40;
};
/** Canonical external agent contract. Keep this module independent of host packages. */
export declare const AGENT_NAME_MAX_LENGTH = 24;
export interface AgentSceneObject {
id?: string;
x: number;
y: number;
emoji: string;
floor?: 0 | 1;
isEntry?: boolean;
message?: string;
targetRoomName?: string;
linkedRoom?: boolean;
portalPairId?: string;
radioConfigured?: boolean;
conveyorEmoji?: '➡️' | '⬇️' | '⬅️' | '⬆️';
pianoNote?: string;
pianoSound?: string;
}
export interface AgentScene {
blocked: number[];
objects: AgentSceneObject[];
floor?: 0 | 1;
hasBasement?: boolean;
editing?: 'everyone' | 'owner';
gameFacts?: {
gemsEnabled: boolean;
gemRefundHours: number;
};
}
/** Version 1 of the external agent gateway. No account IDs, credentials or raw scene state. */
export interface AgentPosition {
x: number;
y: number;
floor: 0 | 1;
}
export interface AgentMovement {
id: string;
target: AgentPosition;
status: 'moving' | 'arrived' | 'stopped' | 'blocked' | 'timed_out' | 'disconnected';
}
export interface AgentPursuit {
id: string;
player: string;
mode: 'follow' | 'approach';
status: 'moving' | 'holding' | 'arrived' | 'stopped' | 'lost' | 'blocked' | 'timed_out';
expiresAt: number;
}
export interface AgentObservation {
protocol: 1;
sessionId: string;
revision: number;
observedAt: number;
room: string;
status: string;
self: (AgentPosition & {
id: string;
name: string;
text: string;
}) | null;
players: Array<AgentPosition & {
id: string;
name: string;
text: string;
textRevision: number;
settled: boolean;
}>;
objects: Array<AgentPosition & {
emoji: string;
text?: string;
}>;
blocked: Array<{
x: number;
y: number;
}>;
movement: AgentMovement | null;
scene?: AgentScene;
roster?: Array<AgentPosition & {
id: string;
name: string;
encounterKey?: string;
}>;
interacting?: boolean;
pursuit?: AgentPursuit | null;
appearance?: {
kind: string;
emoji: string;
model: string | null;
expression?: string;
expressions?: string[];
};
limits: {
radius: number;
heartbeatMs: number;
idleTimeoutMs: number;
maxSpeechLength: number;
};
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
self can be null while connecting. movement is null before a movement operation exists. pursuit may be absent or null. Check these values before reading nested fields, and wait for status === 'connected' with a non-null self before acting. scene, roster, appearance and other optional fields depend on the host contract; do not invent missing data. Use IDs supplied by the current observation.
scene.blocked contains cell indexes (y * 64 + x); top-level blocked contains { x, y } positions. scene.objects provides interaction IDs and navigation metadata, while top-level objects is a nearby object summary. Neither is raw Gizmo state. For conversation visibility and text-revision resets, see Observe and act.
Match a move to its result
scape_move_to and scape_step return { operationId, commandId }. operationId matches movement.id; follow/approach operations instead match pursuit.id. commandId is the retry identifier supplied by the caller or generated by the MCP server. The current implementation uses the same value for both IDs. Keep a caller-generated ID before dispatch if you need to retry after losing a response.
The following uses the call helper above. Pass a reachable destination selected from your current observation on the current floor. It waits for the requested operation, rather than confusing an earlier move's arrived status with this move:
js
async function moveAndConfirm(target) {
let observation = await call('scape_observe');
if (observation.status !== 'connected' || !observation.self) {
throw new Error('Wait for connected presence before moving.');
}
const sessionId = observation.sessionId;
const result = await call('scape_move_to', {
x: target.x, y: target.y, floor: observation.self.floor,
commandId: `t${Date.now()}_${crypto.randomUUID()}`,
});
const deadline = Date.now() + 100_000;
while (Date.now() < deadline) {
observation = await call('scape_observe', {
afterRevision: observation.revision, waitMs: 1000,
});
if (observation.sessionId !== sessionId) {
throw new Error('Session changed; discard the old movement request.');
}
const movement = observation.movement;
if (!movement || movement.id !== result.operationId) continue;
if (movement.status === 'arrived') return observation.self;
if (movement.status !== 'moving') {
throw new Error(`Move ended: ${movement.status}`);
}
}
throw new Error('Movement was not confirmed; observe before deciding again.');
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Run one navigation intent at a time in this example. A later move, step, interaction or stop can replace the operation you are waiting for. A timeout or missing match is not evidence of arrival, and an old request must not be replayed into a new session.
