diff --git a/e2e/mcp/device_test.go b/e2e/mcp/device_test.go index 90654a28a..03dd0c392 100644 --- a/e2e/mcp/device_test.go +++ b/e2e/mcp/device_test.go @@ -29,14 +29,6 @@ import ( "go.probo.inc/probo/e2e/internal/testutil" ) -const createDeviceMutation = ` -mutation CreateDevice($input: CreateDeviceInput!) { - createDevice(input: $input) { - device { id state } - } -} -` - type mcpDevice struct { ID string `json:"id"` OrganizationID string `json:"organization_id"` @@ -49,25 +41,27 @@ type mcpDevice struct { } `json:"latest_postures"` } -func createDeviceViaGraphQL(t *testing.T, owner *testutil.Client, orgID string) string { +func createDeviceViaMCP(t *testing.T, mc *testutil.MCPClient, orgID string) string { t.Helper() var result struct { - CreateDevice struct { - Device struct { - ID string `json:"id"` - State string `json:"state"` - } `json:"device"` - } `json:"createDevice"` + Device mcpDevice `json:"device"` + EnrollmentToken string `json:"enrollment_token"` + ServerURL string `json:"server_url"` + EnrollmentURL string `json:"enrollment_url"` } - owner.MustExecute(createDeviceMutation, map[string]any{ - "input": map[string]any{ - "organizationId": orgID, - }, + mc.CallToolInto("createDevice", map[string]any{ + "organization_id": orgID, }, &result) - require.NotEmpty(t, result.CreateDevice.Device.ID) + require.NotEmpty(t, result.Device.ID) + assert.Equal(t, "PENDING", result.Device.State) + assert.NotEmpty(t, result.EnrollmentToken) + assert.NotEmpty(t, result.ServerURL) + assert.NotEmpty(t, result.EnrollmentURL) + assert.NotNil(t, result.Device.LatestPostures) + assert.Empty(t, result.Device.LatestPostures) - return result.CreateDevice.Device.ID + return result.Device.ID } func TestMCP_Device_Lifecycle(t *testing.T) { @@ -77,7 +71,7 @@ func TestMCP_Device_Lifecycle(t *testing.T) { orgID := owner.GetOrganizationID().String() profileID := factory.CreateUser(owner) - deviceID := createDeviceViaGraphQL(t, owner, orgID) + deviceID := createDeviceViaMCP(t, mc, orgID) // Get var getResult struct { @@ -156,13 +150,59 @@ func TestMCP_Device_Lifecycle(t *testing.T) { assert.Equal(t, "resource not found", msg) } +func TestMCP_Device_CreateWithOwner(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + mc := testutil.NewMCPClient(t, owner) + orgID := owner.GetOrganizationID().String() + profileID := factory.CreateUser(owner) + + var result struct { + Device mcpDevice `json:"device"` + EnrollmentToken string `json:"enrollment_token"` + } + mc.CallToolInto("createDevice", map[string]any{ + "organization_id": orgID, + "owner_id": profileID, + }, &result) + require.NotEmpty(t, result.Device.ID) + assert.Equal(t, "PENDING", result.Device.State) + assert.NotEmpty(t, result.EnrollmentToken) + require.NotNil(t, result.Device.OwnerID) + assert.Equal(t, profileID, *result.Device.OwnerID) +} + +func TestMCP_Device_CreateWithInvalidOwner(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + mc := testutil.NewMCPClient(t, owner) + orgID := owner.GetOrganizationID().String() + + msg := mc.CallToolExpectToolError("createDevice", map[string]any{ + "organization_id": orgID, + "owner_id": orgID, + }) + assert.Equal(t, "owner_id must reference a membership profile of the device organization", msg) + + // Each organization lives in its own tenant, so the tenant-scoped profile + // load fails before the owner organization is ever compared. + otherOwner := testutil.NewClient(t, testutil.RoleOwner) + otherProfileID := factory.CreateUser(otherOwner) + + msg = mc.CallToolExpectToolError("createDevice", map[string]any{ + "organization_id": orgID, + "owner_id": otherProfileID, + }) + assert.Equal(t, "resource not found", msg) +} + func TestMCP_Device_CannotDeletePending(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) mc := testutil.NewMCPClient(t, owner) orgID := owner.GetOrganizationID().String() - deviceID := createDeviceViaGraphQL(t, owner, orgID) + deviceID := createDeviceViaMCP(t, mc, orgID) msg := mc.CallToolExpectToolError("deleteDevice", map[string]any{ "id": deviceID, @@ -173,8 +213,9 @@ func TestMCP_Device_CannotDeletePending(t *testing.T) { func TestMCP_Device_PermissionDenied(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) + mc := testutil.NewMCPClient(t, owner) orgID := owner.GetOrganizationID().String() - deviceID := createDeviceViaGraphQL(t, owner, orgID) + deviceID := createDeviceViaMCP(t, mc, orgID) viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) viewerMC := testutil.NewMCPClient(t, viewer) @@ -183,6 +224,19 @@ func TestMCP_Device_PermissionDenied(t *testing.T) { "id": deviceID, }) assert.Contains(t, msg, "permission denied") + + msg = viewerMC.CallToolExpectToolError("createDevice", map[string]any{ + "organization_id": orgID, + }) + assert.Contains(t, msg, "permission denied") + + employee := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner) + employeeMC := testutil.NewMCPClient(t, employee) + + msg = employeeMC.CallToolExpectToolError("createDevice", map[string]any{ + "organization_id": orgID, + }) + assert.Contains(t, msg, "permission denied") } func TestMCP_Device_EmployeeCannotIncludePostures(t *testing.T) { @@ -195,7 +249,7 @@ func TestMCP_Device_EmployeeCannotIncludePostures(t *testing.T) { employeeMC := testutil.NewMCPClient(t, employee) employeeProfileID := employee.GetProfileID().String() - deviceID := createDeviceViaGraphQL(t, owner, orgID) + deviceID := createDeviceViaMCP(t, ownerMC, orgID) var setOwnerResult struct { Device mcpDevice `json:"device"` diff --git a/packages/n8n-node/nodes/Probo/actions/device/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/device/create.operation.ts new file mode 100644 index 000000000..af2a610cf --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/device/create.operation.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['device'], + operation: ['create'], + }, + }, + default: '', + description: 'The ID of the organization', + required: true, + }, + { + displayName: 'Owner ID', + name: 'ownerId', + type: 'string', + displayOptions: { + show: { + resource: ['device'], + operation: ['create'], + }, + }, + default: '', + description: 'Optional profile ID of the owner', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const ownerId = this.getNodeParameter('ownerId', itemIndex) as string; + + const input: { organizationId: string; ownerId?: string } = { + organizationId, + }; + + if (ownerId) { + input.ownerId = ownerId; + } + + const query = ` + mutation CreateDevice($input: CreateDeviceInput!) { + createDevice(input: $input) { + device { + id + state + } + enrollmentToken + serverUrl + enrollmentUrl + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/device/index.ts b/packages/n8n-node/nodes/Probo/actions/device/index.ts index fe3e578ef..93bb51b6a 100644 --- a/packages/n8n-node/nodes/Probo/actions/device/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/device/index.ts @@ -19,6 +19,7 @@ // SOFTWARE. import type { INodeProperties } from 'n8n-workflow'; +import * as createOp from './create.operation'; import * as deleteOp from './delete.operation'; import * as getOp from './get.operation'; import * as getAllOp from './getAll.operation'; @@ -37,6 +38,12 @@ export const description: INodeProperties[] = [ }, }, options: [ + { + name: 'Create', + value: 'create', + description: 'Create a PENDING ITAM device and enrollment token', + action: 'Create a device', + }, { name: 'Delete', value: 'delete', @@ -70,6 +77,7 @@ export const description: INodeProperties[] = [ ], default: 'getAll', }, + ...createOp.description, ...deleteOp.description, ...getOp.description, ...getAllOp.description, @@ -78,6 +86,7 @@ export const description: INodeProperties[] = [ ]; export { + createOp as create, deleteOp as delete, getOp as get, getAllOp as getAll, diff --git a/pkg/cmd/device/create/create.go b/pkg/cmd/device/create/create.go new file mode 100644 index 000000000..e96bd55df --- /dev/null +++ b/pkg/cmd/device/create/create.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package create + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateDeviceInput!) { + createDevice(input: $input) { + device { + id + state + } + enrollmentToken + serverUrl + enrollmentUrl + } +} +` + +type createResponse struct { + CreateDevice struct { + Device struct { + ID string `json:"id"` + State string `json:"state"` + } `json:"device"` + EnrollmentToken string `json:"enrollmentToken"` + ServerURL string `json:"serverUrl"` + EnrollmentURL string `json:"enrollmentUrl"` + } `json:"createDevice"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagOwner string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a PENDING ITAM device and enrollment token", + Example: ` # Create a device for the default organization + prb device create + + # Create a device with an owner + prb device create --owner `, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + input := map[string]any{ + "organizationId": flagOrg, + } + + if flagOwner != "" { + input["ownerId"] = flagOwner + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + out := f.IOStreams.Out + created := resp.CreateDevice + + _, _ = fmt.Fprintf(out, "Created device %s (%s)\n", created.Device.ID, created.Device.State) + _, _ = fmt.Fprintf(out, "Server URL: %s\n", created.ServerURL) + _, _ = fmt.Fprintf(out, "Enrollment URL: %s\n", created.EnrollmentURL) + _, _ = fmt.Fprintf(out, "\nEnrollment Token (save this now — it will not be shown again and it expires):\n%s\n", created.EnrollmentToken) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagOwner, "owner", "", "Owner profile ID") + + return cmd +} diff --git a/pkg/cmd/device/device.go b/pkg/cmd/device/device.go index f5bc94654..df3038e37 100644 --- a/pkg/cmd/device/device.go +++ b/pkg/cmd/device/device.go @@ -23,6 +23,7 @@ package device import ( "github.com/spf13/cobra" "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/device/create" "go.probo.inc/probo/pkg/cmd/device/delete" "go.probo.inc/probo/pkg/cmd/device/list" "go.probo.inc/probo/pkg/cmd/device/revoke" @@ -36,6 +37,7 @@ func NewCmdDevice(f *cmdutil.Factory) *cobra.Command { Short: "Manage ITAM devices", } + cmd.AddCommand(create.NewCmdCreate(f)) cmd.AddCommand(list.NewCmdList(f)) cmd.AddCommand(view.NewCmdView(f)) cmd.AddCommand(revoke.NewCmdRevoke(f)) diff --git a/pkg/server/api/console/v1/enrollment_url.go b/pkg/itam/enrollment_url.go similarity index 80% rename from pkg/server/api/console/v1/enrollment_url.go rename to pkg/itam/enrollment_url.go index 86383d7ea..1f0f2e1d4 100644 --- a/pkg/server/api/console/v1/enrollment_url.go +++ b/pkg/itam/enrollment_url.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package console_v1 +package itam import ( "fmt" @@ -27,22 +27,22 @@ import ( "go.probo.inc/probo/pkg/baseurl" ) -// enrollmentURLs holds the public API origin and probo:// deep link issued +// EnrollmentURLs holds the public API origin and probo:// deep link issued // when a device enrollment token is created. -type enrollmentURLs struct { +type EnrollmentURLs struct { ServerURL string EnrollmentURL string } -// buildEnrollmentURLs derives the agent server origin and deep link from the +// BuildEnrollmentURLs derives the agent server origin and deep link from the // deployment base URL and a one-shot enrollment token. -func buildEnrollmentURLs(baseURL *baseurl.BaseURL, enrollmentToken string) (enrollmentURLs, error) { +func BuildEnrollmentURLs(baseURL *baseurl.BaseURL, enrollmentToken string) (EnrollmentURLs, error) { if baseURL == nil { - return enrollmentURLs{}, fmt.Errorf("base URL is required") + return EnrollmentURLs{}, fmt.Errorf("base URL is required") } if enrollmentToken == "" { - return enrollmentURLs{}, fmt.Errorf("enrollment token is required") + return EnrollmentURLs{}, fmt.Errorf("enrollment token is required") } serverURL := (&url.URL{ @@ -59,7 +59,7 @@ func buildEnrollmentURLs(baseURL *baseurl.BaseURL, enrollmentToken string) (enro query.Set("token", enrollmentToken) enrollURL.RawQuery = query.Encode() - return enrollmentURLs{ + return EnrollmentURLs{ ServerURL: serverURL, EnrollmentURL: enrollURL.String(), }, nil diff --git a/pkg/server/api/console/v1/enrollment_url_test.go b/pkg/itam/enrollment_url_test.go similarity index 96% rename from pkg/server/api/console/v1/enrollment_url_test.go rename to pkg/itam/enrollment_url_test.go index 976367d3b..0fbb8bd5d 100644 --- a/pkg/server/api/console/v1/enrollment_url_test.go +++ b/pkg/itam/enrollment_url_test.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package console_v1 +package itam_test import ( "net/url" @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/itam" ) func TestBuildEnrollmentURLs(t *testing.T) { @@ -76,7 +77,7 @@ func TestBuildEnrollmentURLs(t *testing.T) { base = parsed } - got, err := buildEnrollmentURLs(base, tt.token) + got, err := itam.BuildEnrollmentURLs(base, tt.token) if tt.wantErrContains != "" { require.Error(t, err) assert.ErrorContains(t, err, tt.wantErrContains) diff --git a/pkg/server/api/console/v1/device_resolvers.go b/pkg/server/api/console/v1/device_resolvers.go index d79d80c8e..f2e90a591 100644 --- a/pkg/server/api/console/v1/device_resolvers.go +++ b/pkg/server/api/console/v1/device_resolvers.go @@ -166,7 +166,7 @@ func (r *mutationResolver) EnrollDevice(ctx context.Context, input types.EnrollD return nil, gqlutils.Internal(ctx) } - urls, err := buildEnrollmentURLs(r.baseURL, result.EnrollmentToken) + urls, err := itam.BuildEnrollmentURLs(r.baseURL, result.EnrollmentToken) if err != nil { r.logger.ErrorCtx(ctx, "cannot build enrollment URLs", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -208,7 +208,7 @@ func (r *mutationResolver) CreateDevice(ctx context.Context, input types.CreateD return nil, gqlutils.Internal(ctx) } - urls, err := buildEnrollmentURLs(r.baseURL, result.EnrollmentToken) + urls, err := itam.BuildEnrollmentURLs(r.baseURL, result.EnrollmentToken) if err != nil { r.logger.ErrorCtx(ctx, "cannot build enrollment URLs", log.Error(err)) return nil, gqlutils.Internal(ctx) diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 39b972dcc..9b9aef5e7 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -7602,3 +7602,46 @@ func (r *Resolver) SetDeviceOwnerTool(ctx context.Context, req *mcp.CallToolRequ Device: types.NewDevice(device, nil), }, nil } + +func (r *Resolver) CreateDeviceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateDeviceInput) (*mcp.CallToolResult, types.CreateDeviceOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, itam.ActionDeviceCreate) + if err != nil { + return nil, types.CreateDeviceOutput{}, err + } + + result, err := r.itamSvc.CreateDevice( + ctx, + scope, + itam.CreateDeviceRequest{ + OrganizationID: input.OrganizationID, + OwnerID: input.OwnerID, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, types.CreateDeviceOutput{}, fmt.Errorf("resource not found") + } + + if errors.Is(err, itam.ErrInvalidOwnerProfile) { + return nil, types.CreateDeviceOutput{}, fmt.Errorf("owner_id must reference a membership profile of the device organization") + } + + r.logger.ErrorCtx(ctx, "cannot create device", log.Error(err)) + + return nil, types.CreateDeviceOutput{}, fmt.Errorf("internal server error") + } + + urls, err := itam.BuildEnrollmentURLs(r.baseURL, result.EnrollmentToken) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot build enrollment URLs", log.Error(err)) + + return nil, types.CreateDeviceOutput{}, fmt.Errorf("internal server error") + } + + return nil, types.CreateDeviceOutput{ + Device: types.NewDevice(result.Device, nil), + EnrollmentToken: result.EnrollmentToken, + ServerURL: urls.ServerURL, + EnrollmentURL: urls.EnrollmentURL, + }, nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 3ffd46b49..d8222ed4a 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -3126,6 +3126,38 @@ components: device: $ref: "#/components/schemas/Device" + CreateDeviceInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + owner_id: + $ref: "#/components/schemas/GID" + description: Optional MembershipProfile GID belonging to the same organization; omit to leave the device unassigned. + + CreateDeviceOutput: + type: object + required: + - device + - enrollment_token + - server_url + - enrollment_url + properties: + device: + $ref: "#/components/schemas/Device" + enrollment_token: + type: string + description: One-shot enrollment token shown only in this response; the agent installer exchanges it via REST /enroll. It expires after the deployment-configured enrollment token lifetime (7 days by default), so do not cache it for a later enrollment attempt. + server_url: + type: string + description: Public API origin for agent --server / deep-link server=. + enrollment_url: + type: string + description: probo://enroll?server=...&token=... deep link for the desktop agent. + RevokeDeviceInput: type: object required: @@ -13519,7 +13551,7 @@ tools: $ref: "#/components/schemas/DeleteAssetOutput" - name: listDevices title: List Devices - description: "List ITAM devices for an organization. Devices cannot be created through the MCP API: enrollment issues a one-shot token that the agent installer exchanges (there is no createDevice tool). Device states: PENDING (enrollment token issued, agent has never checked in), ACTIVE (agent heartbeating), REVOKED (enrollment revoked). Use last_seen_at as the staleness signal. latest_postures is empty unless include_postures is true; when loaded it holds the newest result per check_key and is empty for PENDING devices. Page with size and cursor; when next_cursor is present, pass it as cursor on the next call." + description: "List ITAM devices for an organization. Use createDevice to issue a PENDING device and a one-shot enrollment token for the agent installer. Device states: PENDING (enrollment token issued, agent has never checked in), ACTIVE (agent heartbeating), REVOKED (enrollment revoked). Use last_seen_at as the staleness signal. latest_postures is empty unless include_postures is true; when loaded it holds the newest result per check_key and is empty for PENDING devices. Page with size and cursor; when next_cursor is present, pass it as cursor on the next call." hints: readonly: true destructive: false @@ -13541,6 +13573,18 @@ tools: $ref: "#/components/schemas/GetDeviceInput" outputSchema: $ref: "#/components/schemas/GetDeviceOutput" + - name: createDevice + title: Create Device + description: "Create a PENDING ITAM device and return a one-shot enrollment token (plus server_url and enrollment_url) for the agent installer. The plaintext token is shown only in this response; only its hash is stored. The token also expires after a deployment-configured lifetime (7 days by default), so hand it to the installer now instead of storing it for a later retry: exchanging an expired token fails with enrollment token expired and leaves the device PENDING, and you must call createDevice again to issue a fresh device and token. Optionally assign an owner with owner_id (MembershipProfile GID in the same organization)." + hints: + readonly: false + destructive: false + idempotent: false + openWorld: false + inputSchema: + $ref: "#/components/schemas/CreateDeviceInput" + outputSchema: + $ref: "#/components/schemas/CreateDeviceOutput" - name: revokeDevice title: Revoke Device description: "Irreversibly revoke a device enrollment. Immediately invalidates the device agent API key so the agent stops authenticating and reporting; there is no un-revoke tool. Safe to call more than once: state stays REVOKED and revoked_at keeps its original value. Call this before deleteDevice."